From c0e0b5499037e5dd2643b79be95b76f553db3a8c Mon Sep 17 00:00:00 2001 From: Daniel Aditya Istyana Date: Mon, 6 Feb 2023 14:44:56 +0700 Subject: [PATCH] Reducer Protocol (#62) * add dependencies and its test * update effect and reducer * add reducers, rename anyreducer, delete xct related files * extract storeconfig * task result and task cancellable value * observable * effect * fix package swift * any disposable * import xctdynamicoverlay * update effects * make any disposable public * fix any reducer compatibility * first passed test 5.7 * refactor package.swift * remove anyequatable double * update store * wrap in discardable * [TestStore] - Update to conform changes ReducerProtocol (#65) * progress update teststore * progressing update TestStore * Fixing TestStore & make it success run test * update indent code TestStore, add comment for send action assert testStore * change observable * update observable * rename conflicted name * undo * add flatmap * add xctest dynamic overlay podspec * add xctdo podspec * update exports.swift * update podspec * update typo podspec * fix dispose issue * restructure dependency module, fix test, update podspec and package.swift * update store old behaviour, fix store tests * remove unused code * fix flatmap, add StoreOf * update teststore generics order, remove all spi internals * Rewrite example using Reducer Protocol style (#63) * update store, rewrite basic and scoping example * rewrite environment example * rewrite pullback vc example * update counterview, rewrite iflet * rewrite never equal * update timer example * update test * update old scope and store tests * use OptionalPath * remove duplicated StoreConfig * add ACL for TestReducer class & property * fix default value for useNewScope in StoreConfig * [Reducer Protocol] - Add New Test Cases (#68) * fix passing useNewScope to Store from TestStore * add new test to TestStore * add new TestStoreTest cases * update to fix async task cancel * update StoreTest test case * add more StoreTests test cases * update failingWhenNothingChanges to true by default on new TestStore code * update & add new cases for TestStoreTest * remove import combine * separate oldScope & newScope test on Storetest and TestStoreTest * update testcases based on new default value for for Store and TestStore * [Reducer Protocol] - Support Bootstrap MockKit (#70) Support our Bootstrap MockKit when using ReducerProtocol Dependency Environment * update pullback vc * bump xcode versions * update ci.yml to use xcode 14.1 for example tests * update ci yml again for xcode 14.1 * refactor ci.yml * update xcode 14 1 name * sync with 0.44.1 minus PR 1510 and EffectTask * fix warning * Fix Reducer builder inference issue: https://github.com/pointfreeco/swift-composable-architecture/pull/1591 Implement Test Pt1 * adding more test, still failing dependency test on both store and teststore * https://github.com/pointfreeco/swift-composable-architecture/pull/1570 https://github.com/pointfreeco/swift-composable-architecture/pull/1575 * add usenewscope * Sync Cancellation * Readd PR #1510 and add tests, fix tests examples * add optional reducer, update dependency values tests with binding * sync up to 0.46.0 * sync up to 0.47.2 * sync up to 0.48.0 * bump xctestdynamicoverlay * remove assert function on teststore * add failing when nothing change on scope teststore * update store send and mainqueue syntax --------- Co-authored-by: Andhika Setiadi Co-authored-by: Jefferson Setiawan --- .github/workflows/ci.yml | 31 +- .swift-format | 7 + .../xcshareddata/swiftpm/Package.resolved | 9 + .../1-BasicUsage/BasicUsage+Reducer.swift | 46 +- .../Examples/1-BasicUsage/BasicUsageVC.swift | 4 +- .../EnvironmentDemoVC+Reducer.swift | 122 +- .../2-Environment/EnvironmentDemoVC.swift | 4 +- .../2-Environment/EnvironmentRoute.swift | 48 +- .../2-Environment/EnvironmentVC+Mock.swift | 2 +- .../Examples/3-Scope/ScopingVC+Reducer.swift | 65 +- Examples/Examples/3-Scope/ScopingVC.swift | 10 +- .../4-Pullback/PullbackVC+Reducer.swift | 64 +- Examples/Examples/4-Pullback/PullbackVC.swift | 6 +- .../OptionalIfLetVC+Reducer.swift | 56 +- .../5-OptionalIfLet/OptionalIfLetVC.swift | 6 +- .../6-NeverEqual/NeverEqualVC+Reducer.swift | 45 +- .../Examples/6-NeverEqual/NeverEqualVC.swift | 4 +- .../Examples/7-Timer/TimerVC+Reducer.swift | 44 +- Examples/Examples/7-Timer/TimerVC.swift | 4 +- Examples/Examples/RouteVC.swift | 72 +- .../ExamplesTests/BasicReducerTests.swift | 36 +- .../EnvironmentReducerTests.swift | 75 +- .../NeverEqualReducerTests.swift | 13 +- .../OptionalIfLetReducerTests.swift | 16 +- .../ExamplesTests/PullbackReducerTests.swift | 14 +- .../ExamplesTests/ScopingReducerTests.swift | 21 +- Package.resolved | 9 + Package.swift | 18 +- Package.xcworkspace/contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 52 + RxComposableArchitecture.podspec | 3 +- .../Dependencies.swift | 36 + .../StoreScope.swift | 6 +- .../main.swift | 1 + .../RxComposableArchitecture/Binding.swift | 2 +- .../CustomDump/Internal/AnyType.swift | 22 +- .../CustomDump/XCTAssertNoDifference.swift | 2 + .../Debugging/ReducerInstrumentation.swift | 124 - .../Dependencies/Dependencies/Calendar.swift | 44 + .../Dependencies/Dependencies/Context.swift | 25 + .../Dependencies/Dependencies/Date.swift | 85 + .../Dependencies/Dependencies/Locale.swift | 56 + .../Dependencies/Dependencies/MainQueue.swift | 100 + .../Dependencies/Dependencies/OpenURL.swift | 81 + .../Dependencies/RandomNumberGenerator.swift | 101 + .../Dependencies/Dependencies/TimeZone.swift | 35 + .../Dependencies/URLSession.swift | 108 + .../Dependencies/Dependencies/UUID.swift | 141 + .../Dependencies/Dependency.swift | 98 + .../Dependencies/DependencyContext.swift | 32 + .../Dependencies/DependencyKey.swift | 239 ++ .../Dependencies/DependencyValues.swift | 399 +++ .../Dependencies+OpenExistential.swift | 31 + .../Internal/Dependencies+TypeName.swift | 15 + Sources/RxComposableArchitecture/Effect.swift | 774 +++-- .../Effects/Animation.swift | 71 + .../Effects/Cancellation.swift | 343 ++- .../Effects/ConcurrencySupport.swift | 422 +++ .../Effects/Debouncing.swift | 56 - .../Effects/FireAndForget.swift | 18 - .../Effects/Observable.swift | 348 +++ .../Effects/Observable/Debouncing.swift | 69 + .../Effects/{ => Observable}/Deferring.swift | 18 +- .../Effects/{ => Observable}/Throttling.swift | 77 +- .../Effects/{ => Observable}/Timer.swift | 106 +- .../Effects/TaskResult.swift | 278 ++ .../Internal/AnyDisposable.swift | 16 +- .../Internal/Deprecated.swift | 24 +- .../Internal/Locking.swift | 2 +- .../Internal/OpenExistential.swift | 38 + .../Internal/RuntimeWarnings.swift | 107 +- .../Internal/TaskBox.swift | 12 + .../Internal/TaskCancellableValue.swift | 24 + .../Internal/XCTIsTesting.swift | 11 - .../RxComposableArchitecture/Reducer.swift | 468 --- .../Reducer/AnyReducer/AnyReducer.swift | 716 +++++ .../AnyReducer/AnyReducerCompatibility.swift | 44 + .../AnyReducer/AnyReducerDebug.swift} | 210 +- .../AnyReducer/AnyReducerSignpost.swift | 159 + .../Reducer/ReducerBuilder.swift | 466 +++ .../Reducer/Reducers/CombineReducers.swift | 55 + .../Reducer/Reducers/DebugReducer.swift | 86 + .../DependencyKeyWritingReducer.swift | 177 ++ .../Reducer/Reducers/EmptyReducer.swift | 19 + .../Reducer/Reducers/ForEachReducer.swift | 160 + .../Reducer/Reducers/IfCaseLetReducer.swift | 159 + .../Reducer/Reducers/IfLetReducer.swift | 152 + .../Reducer/Reducers/OptionalReducer.swift | 19 + .../Reducer/Reducers/Reduce.swift | 37 + .../Reducer/Reducers/Scope.swift | 302 ++ .../Reducer/Reducers/SignpostReducer.swift | 77 + .../ReducerProtocol.swift | 372 +++ Sources/RxComposableArchitecture/Store.swift | 807 ++++-- .../StoreConfig.swift | 23 + .../TestSupport/Effect+Failing.swift | 3 +- .../TestSupport/TestStore.swift | 2582 +++++++++++++---- .../TestSupport/XCTFail.swift | 94 - .../BootstrapTests.swift | 6 +- .../DependencyKeyWritingReducerTests.swift | 157 + .../DependencyTests/DependencyKeyTests.swift | 159 + .../DependencyValuesTests.swift | 280 ++ .../EffectCancellationTests.swift | 58 +- .../EffectDebounceTests.swift | 7 +- .../EffectOperationTests.swift | 152 + .../EffectRunTests.swift | 119 + .../EffectTaskTests.swift | 119 + .../EffectTests.swift | 210 +- .../ForEachReducerTests.swift | 115 + .../IfCaseLetReducerTests.swift | 73 + .../IfLetReducerTests.swift | 41 + .../MemoryManagementTests.swift | 55 +- .../NeverEqualTests.swift | 20 +- .../ReducerBuilderTests.swift | 290 ++ .../ReducerTests.swift | 111 +- .../RuntimeWarningTests.swift | 83 + .../RxComposableArchitectureTests.swift | 6 +- .../ScopeTests.swift | 183 ++ .../StoreOldScopeTest.swift | 552 ++++ .../StoreTests.swift | 690 +++-- .../TaskCancellationTests.swift | 45 + .../TaskResultTests.swift | 121 + .../TestStoreFailureTests.swift | 291 ++ .../TestStoreNonExhaustiveTests.swift | 787 +++++ .../TestStoreOldScopeTests.swift | 215 ++ .../TestStoreTests.swift | 387 +++ .../xctest-dynamic-overlay.podspec.json | 20 + 127 files changed, 15120 insertions(+), 2937 deletions(-) create mode 100644 .swift-format create mode 100644 Package.xcworkspace/contents.xcworkspacedata create mode 100644 Package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Package.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Sources/RxComposableArchitecture-Benchmark/Dependencies.swift delete mode 100644 Sources/RxComposableArchitecture/Debugging/ReducerInstrumentation.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/Calendar.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/Context.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/Date.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/Locale.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/MainQueue.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/OpenURL.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/RandomNumberGenerator.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/TimeZone.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/URLSession.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependencies/UUID.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Dependency.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/DependencyContext.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/DependencyKey.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/DependencyValues.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Internal/Dependencies+OpenExistential.swift create mode 100644 Sources/RxComposableArchitecture/Dependencies/Internal/Dependencies+TypeName.swift create mode 100644 Sources/RxComposableArchitecture/Effects/Animation.swift create mode 100644 Sources/RxComposableArchitecture/Effects/ConcurrencySupport.swift delete mode 100644 Sources/RxComposableArchitecture/Effects/Debouncing.swift delete mode 100644 Sources/RxComposableArchitecture/Effects/FireAndForget.swift create mode 100644 Sources/RxComposableArchitecture/Effects/Observable.swift create mode 100644 Sources/RxComposableArchitecture/Effects/Observable/Debouncing.swift rename Sources/RxComposableArchitecture/Effects/{ => Observable}/Deferring.swift (69%) rename Sources/RxComposableArchitecture/Effects/{ => Observable}/Throttling.swift (55%) rename Sources/RxComposableArchitecture/Effects/{ => Observable}/Timer.swift (62%) create mode 100644 Sources/RxComposableArchitecture/Effects/TaskResult.swift create mode 100644 Sources/RxComposableArchitecture/Internal/OpenExistential.swift create mode 100644 Sources/RxComposableArchitecture/Internal/TaskBox.swift create mode 100644 Sources/RxComposableArchitecture/Internal/TaskCancellableValue.swift delete mode 100644 Sources/RxComposableArchitecture/Internal/XCTIsTesting.swift delete mode 100644 Sources/RxComposableArchitecture/Reducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift rename Sources/RxComposableArchitecture/{Debugging/ReducerDebugging.swift => Reducer/AnyReducer/AnyReducerDebug.swift} (52%) create mode 100644 Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerSignpost.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/ReducerBuilder.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/CombineReducers.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/DebugReducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/EmptyReducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/ForEachReducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/IfLetReducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/OptionalReducer.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/Reduce.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/Scope.swift create mode 100644 Sources/RxComposableArchitecture/Reducer/Reducers/SignpostReducer.swift create mode 100644 Sources/RxComposableArchitecture/ReducerProtocol.swift create mode 100644 Sources/RxComposableArchitecture/StoreConfig.swift delete mode 100644 Sources/RxComposableArchitecture/TestSupport/XCTFail.swift create mode 100644 Tests/RxComposableArchitectureTests/DependencyKeyWritingReducerTests.swift create mode 100644 Tests/RxComposableArchitectureTests/DependencyTests/DependencyKeyTests.swift create mode 100644 Tests/RxComposableArchitectureTests/DependencyTests/DependencyValuesTests.swift create mode 100644 Tests/RxComposableArchitectureTests/EffectOperationTests.swift create mode 100644 Tests/RxComposableArchitectureTests/EffectRunTests.swift create mode 100644 Tests/RxComposableArchitectureTests/EffectTaskTests.swift create mode 100644 Tests/RxComposableArchitectureTests/ForEachReducerTests.swift create mode 100644 Tests/RxComposableArchitectureTests/IfCaseLetReducerTests.swift create mode 100644 Tests/RxComposableArchitectureTests/IfLetReducerTests.swift create mode 100644 Tests/RxComposableArchitectureTests/ReducerBuilderTests.swift create mode 100644 Tests/RxComposableArchitectureTests/RuntimeWarningTests.swift create mode 100644 Tests/RxComposableArchitectureTests/ScopeTests.swift create mode 100644 Tests/RxComposableArchitectureTests/StoreOldScopeTest.swift create mode 100644 Tests/RxComposableArchitectureTests/TaskCancellationTests.swift create mode 100644 Tests/RxComposableArchitectureTests/TaskResultTests.swift create mode 100644 Tests/RxComposableArchitectureTests/TestStoreFailureTests.swift create mode 100644 Tests/RxComposableArchitectureTests/TestStoreNonExhaustiveTests.swift create mode 100644 Tests/RxComposableArchitectureTests/TestStoreOldScopeTests.swift create mode 100644 Tests/RxComposableArchitectureTests/TestStoreTests.swift create mode 100644 development-podspecs/xctest-dynamic-overlay.podspec.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ed2706..2c0a46a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,33 +9,46 @@ on: - '*' jobs: - build_monterey: + + build_library: strategy: matrix: xcode: - - '13.4' - - '14.0' + - '13.4.1' + - '14.1' runs-on: macos-12 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Run build run: make build-library - name: Run benchmark run: make benchmark - unit_test_monterey: + + unit_test_library: strategy: matrix: xcode: - - '13.4' - - '14.0' + - '13.4.1' + - '14.1' runs-on: macos-12 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: Run tests run: make test-library - - name: Run Example Tests + + unit_test_example: + runs-on: macos-12 + steps: + - uses: actions/checkout@v3 + - name: Selext Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_14.1.app + - name: Run tests run: make test-example + + + + diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..9cb642d --- /dev/null +++ b/.swift-format @@ -0,0 +1,7 @@ +{ + "indentation" : { + "spaces" : 4 + }, + "tabWidth" : 8, + "version" : 1 +} diff --git a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8880286..c9e585f 100644 --- a/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/Examples.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -36,6 +36,15 @@ "revision": "ce9c0d897db8a840c39de64caaa9b60119cf4be8", "version": "0.8.1" } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "16e6409ee82e1b81390bdffbf217b9c08ab32784", + "version": "0.5.0" + } } ] }, diff --git a/Examples/Examples/1-BasicUsage/BasicUsage+Reducer.swift b/Examples/Examples/1-BasicUsage/BasicUsage+Reducer.swift index 20421f8..1b085e8 100644 --- a/Examples/Examples/1-BasicUsage/BasicUsage+Reducer.swift +++ b/Examples/Examples/1-BasicUsage/BasicUsage+Reducer.swift @@ -7,29 +7,31 @@ import RxComposableArchitecture -struct BasicState: Equatable { - var number: Int - var errorMessage: String? -} - -enum BasicAction: Equatable { - case didTapPlus - case didTapMinus -} - -let basicUsageReducer = Reducer { state, action, _ in - switch action { - case .didTapMinus: - guard state.number > 0 else { - state.errorMessage = "Can't below 0" +struct Basic: ReducerProtocol { + struct State: Equatable { + var number: Int + var errorMessage: String? + } + + enum Action: Equatable { + case didTapPlus + case didTapMinus + } + + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .didTapMinus: + guard state.number > 0 else { + state.errorMessage = "Can't below 0" + return .none + } + state.number -= 1 + state.errorMessage = nil + return .none + case .didTapPlus: + state.number += 1 + state.errorMessage = nil return .none } - state.number -= 1 - state.errorMessage = nil - return .none - case .didTapPlus: - state.number += 1 - state.errorMessage = nil - return .none } } diff --git a/Examples/Examples/1-BasicUsage/BasicUsageVC.swift b/Examples/Examples/1-BasicUsage/BasicUsageVC.swift index 5a545fd..d79f261 100644 --- a/Examples/Examples/1-BasicUsage/BasicUsageVC.swift +++ b/Examples/Examples/1-BasicUsage/BasicUsageVC.swift @@ -32,9 +32,9 @@ class BasicUsageVC: UIScrollVC { return label }() - private let store: Store + private let store: StoreOf - init(store: Store) { + init(store: StoreOf) { self.store = store super.init() title = "Basic Usage of State, Action & Reducer" diff --git a/Examples/Examples/2-Environment/EnvironmentDemoVC+Reducer.swift b/Examples/Examples/2-Environment/EnvironmentDemoVC+Reducer.swift index 5d9fba3..dbc680d 100644 --- a/Examples/Examples/2-Environment/EnvironmentDemoVC+Reducer.swift +++ b/Examples/Examples/2-Environment/EnvironmentDemoVC+Reducer.swift @@ -9,54 +9,34 @@ import CasePaths import Foundation import RxComposableArchitecture import RxSwift +import XCTestDynamicOverlay -struct CustomError: Error, Equatable { - var message: String -} - -struct EnvironmentState: Equatable { - var text: String = "First Load" - var isLoading = false - var alertMessage: String? - var uuidString: String = "NONE" - var currentDate: Date? -} - -enum EnvironmentAction: Equatable { - case didLoad - case receiveData(Result) - case refresh - case getCurrentDate - case generateUUID - case dismissAlert -} - -struct AnalyticsEvent: Equatable { - var name: String - var category: String -} - -class AnalyticManager { - private init() {} - static func track(_ event: AnalyticsEvent) { - print("<<< Track event of \(event)") +struct Environment: ReducerProtocol { + struct State: Equatable { + var text: String = "First Load" + var isLoading = false + var alertMessage: String? + var uuidString: String = "NONE" + var currentDate: Date? } -} - -struct EnvironmentVCEnvironment { - var loadData: () -> Effect> - var trackEvent: (AnalyticsEvent) -> Void - var date: () -> Date - var uuid: () -> UUID -} - -let environmentReducer: Reducer = { - Reducer { state, action, env in + + enum Action: Equatable { + case didLoad + case receiveData(Result) + case refresh + case getCurrentDate + case generateUUID + case dismissAlert + } + + @Dependency(\.envVCEnvironment) var env + + func reduce(into state: inout State, action: Action) -> Effect { switch action { case .didLoad: state.isLoading = true return env.loadData() - .map(EnvironmentAction.receiveData) + .map(Action.receiveData) case let .receiveData(response): state.isLoading = false switch response { @@ -71,7 +51,7 @@ let environmentReducer: Reducer> Tracked: \(event)") + } +} + +struct EnvironmentVCEnvironment { + var loadData: () -> Effect> + var trackEvent: (AnalyticsEvent) -> Void + var date: () -> Date + var uuid: () -> UUID +} + +extension EnvironmentVCEnvironment: DependencyKey { + static var liveValue: EnvironmentVCEnvironment { + EnvironmentVCEnvironment( + loadData: { + Observable.just(Result.success(Int.random(in: 0 ... 10000))) + .delay(.milliseconds(500), scheduler: MainScheduler.instance) + .eraseToEffect() + }, + trackEvent: AnalyticsManager.track(_:), + date: Date.init, + uuid: UUID.init + ) + } + + static var testValue: EnvironmentVCEnvironment { + EnvironmentVCEnvironment( + loadData: unimplemented("\(Self.self).loadData", placeholder: .just(.failure(CustomError(message: "Error"))).eraseToEffect()), + trackEvent: unimplemented("\(Self.self).trackEvent"), + date: unimplemented("\(Self.self).date", placeholder: Date()), + uuid: unimplemented("\(Self.self).uuid", placeholder: UUID()) + ) + } +} + +extension DependencyValues { + var envVCEnvironment: EnvironmentVCEnvironment { + get { self[EnvironmentVCEnvironment.self] } + set { self[EnvironmentVCEnvironment.self] = newValue } + } +} diff --git a/Examples/Examples/2-Environment/EnvironmentDemoVC.swift b/Examples/Examples/2-Environment/EnvironmentDemoVC.swift index 1ff0b90..348f711 100644 --- a/Examples/Examples/2-Environment/EnvironmentDemoVC.swift +++ b/Examples/Examples/2-Environment/EnvironmentDemoVC.swift @@ -44,9 +44,9 @@ class EnvironmentDemoVC: UIScrollVC { private let getDateButton = UIButton.template(title: "Get new Date") private let getUUIDButton = UIButton.template(title: "Get new UUID") - private let store: Store + private let store: StoreOf - init(store: Store) { + init(store: StoreOf) { self.store = store super.init() } diff --git a/Examples/Examples/2-Environment/EnvironmentRoute.swift b/Examples/Examples/2-Environment/EnvironmentRoute.swift index ecdb9be..4da785f 100644 --- a/Examples/Examples/2-Environment/EnvironmentRoute.swift +++ b/Examples/Examples/2-Environment/EnvironmentRoute.swift @@ -29,32 +29,40 @@ class EnvironmentRouteVC: UITableViewController { let selectedRoute = routes[indexPath.row] switch selectedRoute { case .live: - let viewController = EnvironmentDemoVC(store: Store( - initialState: EnvironmentState(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.live - )) + let viewController = EnvironmentDemoVC( + store: Store( + initialState: Environment.State(), + reducer: Environment() + .dependency(\.envVCEnvironment, .live) + ) + ) navigationController?.pushViewController(viewController, animated: true) case .mockSuccess: - let viewController = EnvironmentDemoVC(store: Store( - initialState: EnvironmentState(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.mockSuccess - )) + let viewController = EnvironmentDemoVC( + store: Store( + initialState: Environment.State(), + reducer: Environment() + .dependency(\.envVCEnvironment, .mockSuccess) + ) + ) navigationController?.pushViewController(viewController, animated: true) case .mockFailed: - let viewController = EnvironmentDemoVC(store: Store( - initialState: EnvironmentState(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.mockFailed - )) + let viewController = EnvironmentDemoVC( + store: Store( + initialState: Environment.State(), + reducer: Environment() + .dependency(\.envVCEnvironment, .mockFailed) + ) + ) navigationController?.pushViewController(viewController, animated: true) case .mockRandom: - let viewController = EnvironmentDemoVC(store: Store( - initialState: EnvironmentState(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.mockRandom - )) + let viewController = EnvironmentDemoVC( + store: Store( + initialState: Environment.State(), + reducer: Environment() + .dependency(\.envVCEnvironment, .mockRandom) + ) + ) navigationController?.pushViewController(viewController, animated: true) } } diff --git a/Examples/Examples/2-Environment/EnvironmentVC+Mock.swift b/Examples/Examples/2-Environment/EnvironmentVC+Mock.swift index abebb07..0b08ecc 100644 --- a/Examples/Examples/2-Environment/EnvironmentVC+Mock.swift +++ b/Examples/Examples/2-Environment/EnvironmentVC+Mock.swift @@ -15,7 +15,7 @@ extension EnvironmentVCEnvironment { .delay(.milliseconds(500), scheduler: MainScheduler.instance) .eraseToEffect() }, - trackEvent: AnalyticManager.track, + trackEvent: AnalyticsManager.track, date: Date.init, uuid: UUID.init ) diff --git a/Examples/Examples/3-Scope/ScopingVC+Reducer.swift b/Examples/Examples/3-Scope/ScopingVC+Reducer.swift index b6414dd..2d3437c 100644 --- a/Examples/Examples/3-Scope/ScopingVC+Reducer.swift +++ b/Examples/Examples/3-Scope/ScopingVC+Reducer.swift @@ -8,26 +8,28 @@ import CasePaths import RxComposableArchitecture -internal struct ScopingState: Equatable { - internal var counter = CounterState() -} - -internal enum ScopingAction: Equatable { - case didTapJump - case counter(CounterAction) -} - -internal let scopingReducer = Reducer { state, action, _ in - switch action { - case .didTapJump: - state.counter.number = 100 - return .none - case .counter(.didTapMinus): - state.counter.number -= 1 - return .none - case .counter(.didTapPlus): - state.counter.number += 1 - return .none +internal struct Scoping: ReducerProtocol { + internal struct State: Equatable { + internal var counter = Counter.State() + } + + internal enum Action: Equatable { + case didTapJump + case counter(Counter.Action) + } + + internal func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .didTapJump: + state.counter.number = 100 + return .none + case .counter(.didTapMinus): + state.counter.number -= 1 + return .none + case .counter(.didTapPlus): + state.counter.number += 1 + return .none + } } } @@ -39,3 +41,26 @@ internal enum CounterAction: Equatable { case didTapMinus case didTapPlus } + +internal struct Counter: ReducerProtocol { + internal struct State: Equatable { + internal var number: Int = 0 + } + + internal enum Action: Equatable { + case didTapMinus + case didTapPlus + } + + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .didTapPlus: + state.number += 1 + return .none + case .didTapMinus: + state.number -= 1 + return .none + } + } + +} diff --git a/Examples/Examples/3-Scope/ScopingVC.swift b/Examples/Examples/3-Scope/ScopingVC.swift index e25712c..7b70184 100644 --- a/Examples/Examples/3-Scope/ScopingVC.swift +++ b/Examples/Examples/3-Scope/ScopingVC.swift @@ -29,13 +29,13 @@ class ScopingVC: UIScrollVC { private let counterView: CounterView - private let store: Store + private let store: StoreOf - init(store: Store) { + init(store: StoreOf) { self.store = store counterView = CounterView(store: store.scope( state: \.counter, - action: ScopingAction.counter + action: Scoping.Action.counter )) super.init() } @@ -77,10 +77,10 @@ class CounterView: UIStackView { return label }() - private let store: Store + private let store: StoreOf private let disposeBag = DisposeBag() - init(store: Store) { + init(store: StoreOf) { self.store = store super.init(frame: .zero) alignment = .center diff --git a/Examples/Examples/4-Pullback/PullbackVC+Reducer.swift b/Examples/Examples/4-Pullback/PullbackVC+Reducer.swift index 2dbacdf..71e23b2 100644 --- a/Examples/Examples/4-Pullback/PullbackVC+Reducer.swift +++ b/Examples/Examples/4-Pullback/PullbackVC+Reducer.swift @@ -8,43 +8,35 @@ import CasePaths import RxComposableArchitecture -internal struct PullbackState: Equatable { - internal var text: String = "" - internal var counter = CounterState() -} - -internal enum PullbackAction: Equatable { - case textDidChange(String) - case counter(CounterAction) -} - -internal let pullbackCounterReducer = Reducer { state, action, _ in - switch action { - case .didTapMinus: - state.number -= 1 - return .none - case .didTapPlus: - state.number += 1 - return .none +internal struct Pullback: ReducerProtocol { + internal struct State: Equatable { + internal var text: String = "" + internal var counter = Counter.State(number: 0) } -} + + internal enum Action: Equatable { + case textDidChange(String) + case counter(Counter.Action) + } + + var body: some ReducerProtocolOf { + Reduce { state, action in + switch action { + case .counter(.didTapPlus): + print(">>> trackerDidTapPlus") + return .none -private let defaultReducer = Reducer { state, action, _ in - switch action { - case let .textDidChange(text): - state.text = "You write: \(text)" - return .none - case .counter: - return .none + case let .textDidChange(text): + state.text = "You write: \(text)" + return .none + + default: + return .none + } + } + + Scope(state: \.counter, action: /Action.counter) { + Counter() + } } } - -/// This is where we combine it to the parent reducer -internal let pullbackReducer = Reducer.combine( - pullbackCounterReducer.pullback( - state: \.counter, - action: /PullbackAction.counter, - environment: { _ in } - ), - defaultReducer -) diff --git a/Examples/Examples/4-Pullback/PullbackVC.swift b/Examples/Examples/4-Pullback/PullbackVC.swift index 8f5d88a..e6f24d0 100644 --- a/Examples/Examples/4-Pullback/PullbackVC.swift +++ b/Examples/Examples/4-Pullback/PullbackVC.swift @@ -37,15 +37,15 @@ class PullbackVC: UIScrollVC { private let counterView: CounterView - private let store: Store + private let store: StoreOf private var observation: NSKeyValueObservation? - init(store: Store) { + init(store: StoreOf) { self.store = store counterView = CounterView(store: store.scope( state: \.counter, - action: PullbackAction.counter + action: Pullback.Action.counter )) super.init() } diff --git a/Examples/Examples/5-OptionalIfLet/OptionalIfLetVC+Reducer.swift b/Examples/Examples/5-OptionalIfLet/OptionalIfLetVC+Reducer.swift index 6599505..5eeb732 100644 --- a/Examples/Examples/5-OptionalIfLet/OptionalIfLetVC+Reducer.swift +++ b/Examples/Examples/5-OptionalIfLet/OptionalIfLetVC+Reducer.swift @@ -8,35 +8,33 @@ import CasePaths import RxComposableArchitecture -internal struct OptionalIfLetState: Equatable { - internal var number = 0 - internal var counter: CounterState? -} - -internal enum OptionalIfLetAction: Equatable { - case didToggle - case counter(CounterAction) -} - -internal let optionalIfLetReducer = Reducer.combine( - pullbackCounterReducer.optional() - .pullback( - state: \.counter, - action: /OptionalIfLetAction.counter, - environment: { _ in } - ), - Reducer { state, action, _ in - switch action { - case .didToggle: - if let counter = state.counter { - state.number = counter.number - state.counter = nil - } else { - state.counter = CounterState(number: state.number) +internal struct OptionalIfLet: ReducerProtocol { + internal struct State: Equatable { + internal var number = 0 + internal var counter: Counter.State? + } + + internal enum Action: Equatable { + case didToggle + case counter(Counter.Action) + } + + var body: some ReducerProtocolOf { + Reduce { state, action in + switch action { + case .didToggle: + if let counter = state.counter { + state.number = counter.number + state.counter = nil + } else { + state.counter = Counter.State(number: 0) + } + return .none + case .counter: return .none } - return .none - case .counter: return .none + } + .ifLet(\.counter, action: /Action.counter) { + Counter() } } - -) +} diff --git a/Examples/Examples/5-OptionalIfLet/OptionalIfLetVC.swift b/Examples/Examples/5-OptionalIfLet/OptionalIfLetVC.swift index 4535e66..bb09117 100644 --- a/Examples/Examples/5-OptionalIfLet/OptionalIfLetVC.swift +++ b/Examples/Examples/5-OptionalIfLet/OptionalIfLetVC.swift @@ -35,11 +35,11 @@ class OptionalIfLetVC: UIScrollVC { private var counterView: CounterView? - private let store: Store + private let store: StoreOf private let stackView: UIStackView - init(store: Store) { + init(store: StoreOf){ self.store = store stackView = UIStackView.vertical(subviews: [ explanationTextView, @@ -69,7 +69,7 @@ class OptionalIfLetVC: UIScrollVC { store.scope( state: \.counter, - action: OptionalIfLetAction.counter + action: OptionalIfLet.Action.counter ).ifLet(then: { [weak self] wrappedStore in let counterView = CounterView(store: wrappedStore) self?.counterView = counterView diff --git a/Examples/Examples/6-NeverEqual/NeverEqualVC+Reducer.swift b/Examples/Examples/6-NeverEqual/NeverEqualVC+Reducer.swift index cc47dfe..f4718ac 100644 --- a/Examples/Examples/6-NeverEqual/NeverEqualVC+Reducer.swift +++ b/Examples/Examples/6-NeverEqual/NeverEqualVC+Reducer.swift @@ -7,25 +7,26 @@ import RxComposableArchitecture -struct NeverEqualState: Equatable { - @NeverEqual - var showAlert: String? - @NeverEqual - var scrollToTop: Stateless? - } - - enum NeverEqualAction: Equatable { - case didTapShowAlert - case didTapScrollToTop - } - - internal let neverEqualDemoReducer = Reducer { state, action, _ in - switch action { - case .didTapShowAlert: - state.showAlert = "This is an alert" - return .none - case .didTapScrollToTop: - state.scrollToTop = Stateless() - return .none - } - } +struct NeverEqualExample: ReducerProtocol { + struct State: Equatable { + @NeverEqual var showAlert: String? + @NeverEqual var scrollToTop: Stateless? + } + + enum Action: Equatable { + case didTapShowAlert + case didTapScrollToTop + } + + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .didTapShowAlert: + state.showAlert = "This is an alert" + return .none + case .didTapScrollToTop: + state.scrollToTop = Stateless() + return .none + } + } + +} diff --git a/Examples/Examples/6-NeverEqual/NeverEqualVC.swift b/Examples/Examples/6-NeverEqual/NeverEqualVC.swift index f9e373e..8dcf7ee 100644 --- a/Examples/Examples/6-NeverEqual/NeverEqualVC.swift +++ b/Examples/Examples/6-NeverEqual/NeverEqualVC.swift @@ -34,9 +34,9 @@ class NeverEqualVC: UIScrollVC { private let scrollToTopButton = UIButton.template(title: "Scroll to Top") - private let store: Store + private let store: StoreOf - init(store: Store) { + init(store: StoreOf) { self.store = store super.init() title = "NeverEqual Demo" diff --git a/Examples/Examples/7-Timer/TimerVC+Reducer.swift b/Examples/Examples/7-Timer/TimerVC+Reducer.swift index 0d779bf..5dcbbce 100644 --- a/Examples/Examples/7-Timer/TimerVC+Reducer.swift +++ b/Examples/Examples/7-Timer/TimerVC+Reducer.swift @@ -8,26 +8,28 @@ import RxComposableArchitecture import RxSwift -struct TimerState: Equatable { - var tickCount: Int = 0 -} - -enum TimerAction: Equatable { - case onDidLoad - case onTimerTick -} - -internal let timerDemoReducer = Reducer { state, action, _ in - switch action { - case .onDidLoad: - return Effect.timer(id: "0", every: .seconds(1), on: MainScheduler.instance) - .map { _ in - print(">> Timer tick") - return TimerAction.onTimerTick - } - - case .onTimerTick: - state.tickCount += 1 - return .none +struct TimerExample: ReducerProtocol { + struct State: Equatable { + var tickCount: Int = 0 + } + + enum Action: Equatable { + case onDidLoad + case onTimerTick + } + + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .onDidLoad: + return Effect.timer(id: "0", every: .seconds(1), on: MainScheduler.instance) + .map { _ in + print(">> Timer tick") + return Action.onTimerTick + } + + case .onTimerTick: + state.tickCount += 1 + return .none + } } } diff --git a/Examples/Examples/7-Timer/TimerVC.swift b/Examples/Examples/7-Timer/TimerVC.swift index 9b2fcfc..c664676 100644 --- a/Examples/Examples/7-Timer/TimerVC.swift +++ b/Examples/Examples/7-Timer/TimerVC.swift @@ -19,9 +19,9 @@ class TimerVC: UIScrollVC { private let tickCountLabel = UILabel() - private let store: Store + private let store: StoreOf - init(store: Store) { + init(store: StoreOf) { self.store = store super.init() title = "Timer Demo" diff --git a/Examples/Examples/RouteVC.swift b/Examples/Examples/RouteVC.swift index 1ad0f62..1435f7f 100644 --- a/Examples/Examples/RouteVC.swift +++ b/Examples/Examples/RouteVC.swift @@ -32,54 +32,54 @@ class RouteVC: UITableViewController { let selectedRoute = routes[indexPath.row] switch selectedRoute { case .basic: - let viewController = BasicUsageVC(store: Store( - initialState: BasicState(number: 0), - reducer: basicUsageReducer, - environment: (), - useNewScope: true - )) + let viewController = BasicUsageVC( + store: StoreOf( + initialState: Basic.State(number: 0), + reducer: Basic() + ) + ) navigationController?.pushViewController(viewController, animated: true) case .environment: navigationController?.pushViewController(EnvironmentRouteVC(), animated: true) case .scoping: - let viewController = ScopingVC(store: Store( - initialState: ScopingState(), - reducer: scopingReducer, - environment: (), - useNewScope: true - )) + let viewController = ScopingVC( + store: StoreOf( + initialState: Scoping.State(), + reducer: Scoping() + ) + ) navigationController?.pushViewController(viewController, animated: true) case .pullback: - let viewController = PullbackVC(store: Store( - initialState: PullbackState(), - reducer: pullbackReducer, - environment: (), - useNewScope: true - )) + let viewController = PullbackVC( + store: Store( + initialState: Pullback.State(), + reducer: Pullback() + ) + ) navigationController?.pushViewController(viewController, animated: true) case .optionalIfLet: - let viewController = OptionalIfLetVC(store: Store( - initialState: OptionalIfLetState(), - reducer: optionalIfLetReducer, - environment: (), - useNewScope: true - )) + let viewController = OptionalIfLetVC( + store: Store( + initialState: OptionalIfLet.State(), + reducer: OptionalIfLet() + ) + ) navigationController?.pushViewController(viewController, animated: true) case .neverEqual: - let viewController = NeverEqualVC(store: Store( - initialState: NeverEqualState(), - reducer: neverEqualDemoReducer, - environment: (), - useNewScope: true - )) + let viewController = NeverEqualVC( + store: Store( + initialState: NeverEqualExample.State(), + reducer: NeverEqualExample() + ) + ) navigationController?.pushViewController(viewController, animated: true) case .timer: - let viewController = TimerVC(store: Store( - initialState: TimerState(), - reducer: timerDemoReducer, - environment: (), - useNewScope: true - )) + let viewController = TimerVC( + store: Store( + initialState: TimerExample.State(), + reducer: TimerExample() + ) + ) navigationController?.pushViewController(viewController, animated: true) } } diff --git a/Examples/ExamplesTests/BasicReducerTests.swift b/Examples/ExamplesTests/BasicReducerTests.swift index adb2d96..77245c2 100644 --- a/Examples/ExamplesTests/BasicReducerTests.swift +++ b/Examples/ExamplesTests/BasicReducerTests.swift @@ -7,12 +7,18 @@ import RxComposableArchitecture import XCTest + @testable import Examples class BasicReducerTests: XCTestCase { func testTapPlus() { - let testStore = TestStore(initialState: BasicState(number: 0), reducer: basicUsageReducer, environment: (), useNewScope: true) + let testStore = TestStore( + initialState: Basic.State(number: 0), + reducer: Basic(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.didTapPlus) { $0.number = 1 @@ -20,7 +26,12 @@ class BasicReducerTests: XCTestCase { } func testTapMinus() { - let testStore = TestStore(initialState: BasicState(number: 5), reducer: basicUsageReducer, environment: (), useNewScope: true) + let testStore = TestStore( + initialState: Basic.State(number: 5), + reducer: Basic(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.didTapMinus) { $0.number = 4 @@ -28,7 +39,12 @@ class BasicReducerTests: XCTestCase { } func testTapMinusOnZero() { - let testStore = TestStore(initialState: BasicState(number: 0), reducer: basicUsageReducer, environment: (), useNewScope: true) + let testStore = TestStore( + initialState: Basic.State(number: 0), + reducer: Basic(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.didTapMinus) { $0.errorMessage = "Can't below 0" @@ -36,7 +52,12 @@ class BasicReducerTests: XCTestCase { } func testShouldResetErrorWhenTappingPlus() { - let testStore = TestStore(initialState: BasicState(number: 0, errorMessage: "SomeError"), reducer: basicUsageReducer, environment: (), useNewScope: true) + let testStore = TestStore( + initialState: Basic.State(number: 0, errorMessage: "SomeError"), + reducer: Basic(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.didTapPlus) { $0.number = 1 @@ -45,7 +66,12 @@ class BasicReducerTests: XCTestCase { } func testShouldResetErrorWhenTappingMinusWithNumberGreaterThanZero() { - let testStore = TestStore(initialState: BasicState(number: 1, errorMessage: "SomeError"), reducer: basicUsageReducer, environment: (), useNewScope: true) + let testStore = TestStore( + initialState: Basic.State(number: 1, errorMessage: "SomeError"), + reducer: Basic(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.didTapMinus) { $0.number = 0 diff --git a/Examples/ExamplesTests/EnvironmentReducerTests.swift b/Examples/ExamplesTests/EnvironmentReducerTests.swift index a9ff644..3b42647 100644 --- a/Examples/ExamplesTests/EnvironmentReducerTests.swift +++ b/Examples/ExamplesTests/EnvironmentReducerTests.swift @@ -8,6 +8,8 @@ import XCTest import RxComposableArchitecture import RxSwift +import XCTestDynamicOverlay + @testable import Examples final class EnvironmentReducerTests: XCTestCase { @@ -23,12 +25,12 @@ final class EnvironmentReducerTests: XCTestCase { func testSuccessLoadData() { let store = TestStore( - initialState: .init(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.failing + initialState: Environment.State(), + reducer: Environment(), + failingWhenNothingChange: true, + useNewScope: true ) - - store.environment.loadData = { Observable.just(.success(2)).eraseToEffect() } + store.dependencies.envVCEnvironment.loadData = { Observable.just(.success(2)).eraseToEffect() } store.send(.didLoad) { $0.isLoading = true @@ -41,11 +43,13 @@ final class EnvironmentReducerTests: XCTestCase { func testFailedLoadData() { let store = TestStore( - initialState: .init(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.failing + initialState: Environment.State(), + reducer: Environment(), + failingWhenNothingChange: true, + useNewScope: true ) - store.environment.loadData = { Effect(value: .failure(CustomError(message: "ERROR!"))) } + store.dependencies.envVCEnvironment.loadData = { Effect(value: .failure(CustomError(message: "ERROR!"))) } + store.send(.didLoad) { $0.isLoading = true } @@ -60,12 +64,13 @@ final class EnvironmentReducerTests: XCTestCase { func testRefresh() { let store = TestStore( - initialState: .init(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.failing + initialState: Environment.State(), + reducer: Environment(), + failingWhenNothingChange: true, + useNewScope: true ) - store.environment.loadData = { Effect(value: .success(2)) } - store.environment.trackEvent = trackEventHandler + store.dependencies.envVCEnvironment.loadData = { Effect(value: .success(2)) } + store.dependencies.envVCEnvironment.trackEvent = trackEventHandler store.send(.refresh) { $0.isLoading = true } @@ -75,7 +80,7 @@ final class EnvironmentReducerTests: XCTestCase { } /// How you modify the environment value - store.environment.loadData = { + store.dependencies.envVCEnvironment.loadData = { Effect(value: .success(5)) } store.send(.refresh) { @@ -94,13 +99,13 @@ final class EnvironmentReducerTests: XCTestCase { func testGetCurrentDate() { let store = TestStore( - initialState: .init(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.failing + initialState: Environment.State(), + reducer: Environment(), + failingWhenNothingChange: true, + useNewScope: true ) - - store.environment.date = { Date(timeIntervalSince1970: 1_597_300_000) } - store.environment.trackEvent = trackEventHandler + store.dependencies.envVCEnvironment.date = { Date(timeIntervalSince1970: 1_597_300_000) } + store.dependencies.envVCEnvironment.trackEvent = trackEventHandler store.send(.getCurrentDate) { $0.currentDate = Date(timeIntervalSince1970: 1_597_300_000) } @@ -109,13 +114,14 @@ final class EnvironmentReducerTests: XCTestCase { func testGenerateUUID() { let store = TestStore( - initialState: .init(), - reducer: environmentReducer, - environment: EnvironmentVCEnvironment.failing + initialState: Environment.State(), + reducer: Environment(), + failingWhenNothingChange: true, + useNewScope: true ) - store.environment.uuid = UUID.incrementing - store.environment.trackEvent = trackEventHandler + store.dependencies.envVCEnvironment.uuid = UUID.incrementing + store.dependencies.envVCEnvironment.trackEvent = trackEventHandler store.send(.generateUUID) { $0.uuidString = "00000000-0000-0000-0000-000000000000" } @@ -129,20 +135,3 @@ final class EnvironmentReducerTests: XCTestCase { ]) } } - -extension EnvironmentVCEnvironment { - static var failing = Self( - loadData: { - Effect.failing("loadData should not called") - }, - trackEvent: { _ in XCTFail("trackEvent Should not be called") }, - date: { - XCTFail("date Should not be called") - return Date() - }, - uuid: { - XCTFail("date Should not be called") - return UUID() - } - ) -} diff --git a/Examples/ExamplesTests/NeverEqualReducerTests.swift b/Examples/ExamplesTests/NeverEqualReducerTests.swift index e3a4ea2..5b7a04f 100644 --- a/Examples/ExamplesTests/NeverEqualReducerTests.swift +++ b/Examples/ExamplesTests/NeverEqualReducerTests.swift @@ -13,9 +13,9 @@ import XCTest final class NeverEqualReducerTests: XCTestCase { internal func testTapToast() { let testStore = TestStore( - initialState: NeverEqualState(), - reducer: neverEqualDemoReducer, - environment: (), + initialState: NeverEqualExample.State(), + reducer: NeverEqualExample(), + failingWhenNothingChange: true, useNewScope: true ) testStore.send(.didTapShowAlert) { @@ -29,12 +29,11 @@ final class NeverEqualReducerTests: XCTestCase { internal func testTapScrollToTop() { let testStore = TestStore( - initialState: NeverEqualState(), - reducer: neverEqualDemoReducer, - environment: (), + initialState: NeverEqualExample.State(), + reducer: NeverEqualExample(), + failingWhenNothingChange: true, useNewScope: true ) - testStore.stateDiffMode = .full testStore.send(.didTapScrollToTop) { $0.scrollToTop = Stateless() } diff --git a/Examples/ExamplesTests/OptionalIfLetReducerTests.swift b/Examples/ExamplesTests/OptionalIfLetReducerTests.swift index b007d78..530a1cf 100644 --- a/Examples/ExamplesTests/OptionalIfLetReducerTests.swift +++ b/Examples/ExamplesTests/OptionalIfLetReducerTests.swift @@ -12,14 +12,24 @@ import RxSwift class OptionalIfLetReducerTests: XCTestCase { func testDidSwitchToggle() { - let testStore = TestStore(initialState: OptionalIfLetState(), reducer: optionalIfLetReducer, environment: ()) + let testStore = TestStore( + initialState: OptionalIfLet.State(), + reducer: OptionalIfLet(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.didToggle) { - $0.counter = CounterState() + $0.counter = Counter.State() } } func testChangeCounterThenToggle() { - let testStore = TestStore(initialState: OptionalIfLetState(number: 0, counter: CounterState(number: 10)), reducer: optionalIfLetReducer, environment: ()) + let testStore = TestStore( + initialState: OptionalIfLet.State(number: 0, counter: Counter.State(number: 10)), + reducer: OptionalIfLet(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.counter(.didTapPlus)) { $0.counter!.number = 11 } diff --git a/Examples/ExamplesTests/PullbackReducerTests.swift b/Examples/ExamplesTests/PullbackReducerTests.swift index bcb0f70..919ee0c 100644 --- a/Examples/ExamplesTests/PullbackReducerTests.swift +++ b/Examples/ExamplesTests/PullbackReducerTests.swift @@ -12,14 +12,24 @@ import RxSwift class PullbackReducerTests: XCTestCase { func testDidChangeText() { - let testStore = TestStore(initialState: PullbackState(), reducer: pullbackReducer, environment: ()) + let testStore = TestStore( + initialState: Pullback.State(), + reducer: Pullback(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.textDidChange("Hello")) { $0.text = "You write: Hello" } } func testSmallerReducer() { - let testStore = TestStore(initialState: CounterState(), reducer: pullbackCounterReducer, environment: ()) + let testStore = TestStore( + initialState: Counter.State(), + reducer: Counter(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.didTapMinus) { $0.number = -1 } diff --git a/Examples/ExamplesTests/ScopingReducerTests.swift b/Examples/ExamplesTests/ScopingReducerTests.swift index be1fb4d..a889d00 100644 --- a/Examples/ExamplesTests/ScopingReducerTests.swift +++ b/Examples/ExamplesTests/ScopingReducerTests.swift @@ -12,7 +12,12 @@ import RxSwift class ScopingReducerTests: XCTestCase { func testTapPlus() { - let testStore = TestStore(initialState: ScopingState(), reducer: scopingReducer, environment: (), useNewScope: true) + let testStore = TestStore( + initialState: Scoping.State(), + reducer: Scoping(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.counter(.didTapPlus)) { $0.counter.number = 1 @@ -20,7 +25,12 @@ class ScopingReducerTests: XCTestCase { } func testTapMinus() { - let testStore = TestStore(initialState: ScopingState(), reducer: scopingReducer, environment: (), useNewScope: true) + let testStore = TestStore( + initialState: Scoping.State(), + reducer: Scoping(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.counter(.didTapMinus)) { $0.counter.number = -1 @@ -28,7 +38,12 @@ class ScopingReducerTests: XCTestCase { } func testTapJumpButton() { - let testStore = TestStore(initialState: ScopingState(), reducer: scopingReducer, environment: (), useNewScope: true) + let testStore = TestStore( + initialState: Scoping.State(), + reducer: Scoping(), + failingWhenNothingChange: true, + useNewScope: true + ) testStore.send(.didTapJump) { $0.counter.number = 100 diff --git a/Package.resolved b/Package.resolved index 8880286..db9fa19 100644 --- a/Package.resolved +++ b/Package.resolved @@ -36,6 +36,15 @@ "revision": "ce9c0d897db8a840c39de64caaa9b60119cf4be8", "version": "0.8.1" } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "a9daebf0bf65981fd159c885d504481a65a75f02", + "version": "0.8.0" + } } ] }, diff --git a/Package.swift b/Package.swift index 427077e..3405628 100644 --- a/Package.swift +++ b/Package.swift @@ -6,18 +6,20 @@ import PackageDescription let package = Package( name: "RxComposableArchitecture", platforms: [ - .iOS(.v11), + .iOS(.v13), .macOS(.v10_15), ], products: [ .library( name: "RxComposableArchitecture", - targets: ["RxComposableArchitecture"]), + targets: ["RxComposableArchitecture"] + ), ], dependencies: [ .package(url: "https://github.com/ReactiveX/RxSwift", from: "5.1.1"), .package(url: "https://github.com/pointfreeco/swift-case-paths", from: "0.8.1"), .package(name: "Benchmark", url: "https://github.com/google/swift-benchmark", from: "0.1.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.8.0"), ], targets: [ .target( @@ -26,15 +28,19 @@ let package = Package( .product(name: "CasePaths", package: "swift-case-paths"), .product(name: "RxSwift", package: "RxSwift"), .product(name: "RxRelay", package: "RxSwift"), - ]), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + ] + ), .testTarget( name: "RxComposableArchitectureTests", - dependencies: ["RxComposableArchitecture"]), + dependencies: ["RxComposableArchitecture"] + ), .executableTarget( name: "RxComposableArchitecture-Benchmark", dependencies: [ "RxComposableArchitecture", - .product(name: "Benchmark", package: "Benchmark") - ]) + .product(name: "Benchmark", package: "Benchmark"), + ] + ), ] ) diff --git a/Package.xcworkspace/contents.xcworkspacedata b/Package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Package.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Package.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..925ffad --- /dev/null +++ b/Package.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,52 @@ +{ + "object": { + "pins": [ + { + "package": "RxSwift", + "repositoryURL": "https://github.com/ReactiveX/RxSwift", + "state": { + "branch": null, + "revision": "cec68169a048a079f461ba203fe85636548d7a89", + "version": "5.1.3" + } + }, + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser", + "state": { + "branch": null, + "revision": "9f39744e025c7d377987f30b03770805dcb0bcd1", + "version": "1.1.4" + } + }, + { + "package": "Benchmark", + "repositoryURL": "https://github.com/google/swift-benchmark", + "state": { + "branch": null, + "revision": "8163295f6fe82356b0bcf8e1ab991645de17d096", + "version": "0.1.2" + } + }, + { + "package": "swift-case-paths", + "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", + "state": { + "branch": null, + "revision": "bb436421f57269fbcfe7360735985321585a86e5", + "version": "0.10.1" + } + }, + { + "package": "xctest-dynamic-overlay", + "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state": { + "branch": null, + "revision": "16e6409ee82e1b81390bdffbf217b9c08ab32784", + "version": "0.5.0" + } + } + ] + }, + "version": 1 +} diff --git a/RxComposableArchitecture.podspec b/RxComposableArchitecture.podspec index 0d0d698..0130b27 100644 --- a/RxComposableArchitecture.podspec +++ b/RxComposableArchitecture.podspec @@ -25,10 +25,11 @@ Pod::Spec.new do |s| s.ios.deployment_target = '11.0' s.source_files = [ - 'Sources/RxComposableArchitecture/**/*' + 'Sources/RxComposableArchitecture/**/*', ] s.dependency 'RxSwift', '5.1.1' s.dependency 'RxCocoa', '5.1.1' s.dependency 'CasePaths' + s.dependency 'XCTestDynamicOverlay' end diff --git a/Sources/RxComposableArchitecture-Benchmark/Dependencies.swift b/Sources/RxComposableArchitecture-Benchmark/Dependencies.swift new file mode 100644 index 0000000..70e35fb --- /dev/null +++ b/Sources/RxComposableArchitecture-Benchmark/Dependencies.swift @@ -0,0 +1,36 @@ +import Benchmark +import RxComposableArchitecture +import Foundation + +let dependenciesSuite = BenchmarkSuite(name: "Dependencies") { suite in + #if swift(>=5.7) + let reducer: some ReducerProtocol = BenchmarkReducer() + .dependency(\.calendar, .autoupdatingCurrent) + .dependency(\.date, .init { Date() }) + .dependency(\.locale, .autoupdatingCurrent) + .dependency(\.timeZone, .autoupdatingCurrent) + .dependency(\.uuid, .init { UUID() }) + + suite.benchmark("Dependency key writing") { + var state = 0 + _ = reducer.reduce(into: &state, action: ()) + precondition(state == 1) + } + #endif +} + +private struct BenchmarkReducer: ReducerProtocol { + @Dependency(\.someValue) var someValue + func reduce(into state: inout Int, action: Void) -> Effect { + state = self.someValue + return .none + } +} +private enum SomeValueKey: DependencyKey { + static let liveValue = 1 +} +extension DependencyValues { + var someValue: Int { + self[SomeValueKey.self] + } +} diff --git a/Sources/RxComposableArchitecture-Benchmark/StoreScope.swift b/Sources/RxComposableArchitecture-Benchmark/StoreScope.swift index dbf3123..9ab011d 100644 --- a/Sources/RxComposableArchitecture-Benchmark/StoreScope.swift +++ b/Sources/RxComposableArchitecture-Benchmark/StoreScope.swift @@ -2,7 +2,7 @@ import Benchmark import RxComposableArchitecture let storeScopeSuite = BenchmarkSuite(name: "Store scoping") { suite in - let counterReducer = Reducer { state, action, _ in + let counterReducer = AnyReducer { state, action, _ in if action { state += 1 return .none @@ -20,12 +20,12 @@ let storeScopeSuite = BenchmarkSuite(name: "Store scoping") { suite in let lastViewStore = viewStores.last! suite.benchmark("Nested store") { - lastViewStore.send(true) + _ = lastViewStore.send(true) } } let newStoreScopeSuite = BenchmarkSuite(name: "[NEW] Store scoping, with rescope") { suite in - let counterReducer = Reducer { state, action, _ in + let counterReducer = AnyReducer { state, action, _ in if action { state += 1 return .none diff --git a/Sources/RxComposableArchitecture-Benchmark/main.swift b/Sources/RxComposableArchitecture-Benchmark/main.swift index 5387f4b..38827a7 100644 --- a/Sources/RxComposableArchitecture-Benchmark/main.swift +++ b/Sources/RxComposableArchitecture-Benchmark/main.swift @@ -6,4 +6,5 @@ Benchmark.main([ effectSuite, storeScopeSuite, newStoreScopeSuite, + dependenciesSuite, ]) diff --git a/Sources/RxComposableArchitecture/Binding.swift b/Sources/RxComposableArchitecture/Binding.swift index 0b3bbd0..8299ada 100644 --- a/Sources/RxComposableArchitecture/Binding.swift +++ b/Sources/RxComposableArchitecture/Binding.swift @@ -182,7 +182,7 @@ public struct BindingAction: Equatable { } } -extension Reducer { +extension AnyReducer { /// Returns a reducer that applies `BindingAction` mutations to `State` before running this /// reducer's logic. /// diff --git a/Sources/RxComposableArchitecture/CustomDump/Internal/AnyType.swift b/Sources/RxComposableArchitecture/CustomDump/Internal/AnyType.swift index 22cc21d..eb7ee74 100644 --- a/Sources/RxComposableArchitecture/CustomDump/Internal/AnyType.swift +++ b/Sources/RxComposableArchitecture/CustomDump/Internal/AnyType.swift @@ -1,13 +1,13 @@ +@usableFromInline func typeName(_ type: Any.Type) -> String { - var name = _typeName(type) - if let index = name.firstIndex(of: ".") { - name.removeSubrange(...index) - } - return - name - .replacingOccurrences( - of: #"<.+>|\(unknown context at \$[[:xdigit:]]+\)\."#, - with: "", - options: .regularExpression - ) + var name = _typeName(type) + if let index = name.firstIndex(of: ".") { + name.removeSubrange(...index) + } + return name + .replacingOccurrences( + of: #"<.+>|\(unknown context at \$[[:xdigit:]]+\)\."#, + with: "", + options: .regularExpression + ) } diff --git a/Sources/RxComposableArchitecture/CustomDump/XCTAssertNoDifference.swift b/Sources/RxComposableArchitecture/CustomDump/XCTAssertNoDifference.swift index 63d2404..87e4676 100644 --- a/Sources/RxComposableArchitecture/CustomDump/XCTAssertNoDifference.swift +++ b/Sources/RxComposableArchitecture/CustomDump/XCTAssertNoDifference.swift @@ -38,6 +38,8 @@ /// - line: The line number where the failure occurs. The default is the line number where you /// call this function. #if DEBUG +import XCTestDynamicOverlay + public func XCTAssertNoDifference( _ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, diff --git a/Sources/RxComposableArchitecture/Debugging/ReducerInstrumentation.swift b/Sources/RxComposableArchitecture/Debugging/ReducerInstrumentation.swift deleted file mode 100644 index 87ba211..0000000 --- a/Sources/RxComposableArchitecture/Debugging/ReducerInstrumentation.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// ReducerInstrumentation.swift -// RxComposableArchitecture -// -// Created by Wendy Liga on 28/05/20. -// - -import os.signpost -import RxSwift - -extension Reducer { - /// Instruments the reducer with - /// - /// ⚠️ Only works on iOS 12 or newer - /// - /// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data). - /// Each invocation of the reducer will be measured by an interval, and the lifecycle of its - /// effects will be measured with interval and event signposts. - /// - /// To use, build your app for Instruments (⌘I), create a blank instrument, and then use the "+" - /// icon at top right to add the signpost instrument. Start recording your app (red button at top - /// left) and then you should see timing information for every action sent to the store and every - /// effect executed. - /// - /// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living - /// effects. For example, if you start an effect (e.g. a location manager) in `onAppear` and - /// forget to tear down the effect in `onDisappear`, it will clearly show in Instruments that the - /// effect never completed. - /// - /// - Parameters: - /// - prefix: A string to print at the beginning of the formatted message for the signpost. - /// - log: An `OSLog` to use for signposts. - /// - Returns: A reducer that has been enhanced with instrumentation. - public func signpost( - _ prefix: String = "", - log: OSLog = OSLog( - subsystem: "com.tokopedia.Tokopedia", - category: "Reducer Instrumentation" - ) - ) -> Self { - if #available(iOS 12.0, *) { - guard log.signpostsEnabled else { return self } - - // NB: Prevent rendering as "N/A" in Instruments - let zeroWidthSpace = "\u{200B}" - - let prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] " - - return Self { state, action, environment in - var actionOutput: String! - if log.signpostsEnabled { - actionOutput = debugCaseOutput(action) - os_signpost(.begin, log: log, name: "Action", "%s%s", prefix, actionOutput) - } - let effects = self.run(&state, action, environment) - if log.signpostsEnabled { - os_signpost(.end, log: log, name: "Action") - return - effects - .effectSignpost(prefix, log: log, actionOutput: actionOutput) - .eraseToEffect() - } - return effects - } - } else { - return self - } - } -} - -extension ObservableType { - @available(iOS 12.0, *) - internal func effectSignpost(_ prefix: String, log: OSLog, actionOutput: String) -> Observable { - let sid = OSSignpostID(log: log) - - return `do`( - onNext: { _ in - os_signpost(.event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput) - }, - onCompleted: { - os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) - }, - onSubscribe: { - os_signpost(.begin, log: log, name: "Effect", signpostID: sid, "%sStarting from %s", prefix, actionOutput) - }, - onSubscribed: { - os_signpost(.begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, actionOutput) - }, - onDispose: { - os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sDisposed", prefix) - } - ) - } -} - -internal func debugCaseOutput(_ value: Any) -> String { - func debugCaseOutputHelp(_ value: Any) -> String { - let mirror = Mirror(reflecting: value) - switch mirror.displayStyle { - case .enum: - guard let child = mirror.children.first else { - let childOutput = "\(value)" - return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)" - } - let childOutput = debugCaseOutputHelp(child.value) - return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" - case .tuple: - return mirror.children.map { label, value in - let childOutput = debugCaseOutputHelp(value) - return - "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")" - } - .joined(separator: ", ") - default: - return "" - } - } - - return "\(type(of: value))\(debugCaseOutputHelp(value))" -} - -private func isUnlabeledArgument(_ label: String) -> Bool { - !label.contains(where: { $0 != "." && !$0.isNumber }) -} diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/Calendar.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/Calendar.swift new file mode 100644 index 0000000..401cb2b --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/Calendar.swift @@ -0,0 +1,44 @@ +import Foundation +import XCTestDynamicOverlay + +#if swift(>=5.6) + extension DependencyValues { + /// The current calendar that features should use when handling dates. + /// + /// By default, the calendar returned from `Calendar.autoupdatingCurrent` is supplied. When used + /// in a testing context, access will call to `XCTFail` when invoked, unless explicitly + /// overridden using ``withValue(_:_:operation:)-705n``: + /// + /// ```swift + /// DependencyValues.withValue(\.calendar, Calendar(identifier: .gregorian)) { + /// // Assertions... + /// } + /// ``` + /// + /// Or, if you are using the Composable Architecture, you can override dependencies directly + /// on the `TestStore`: + /// + /// ```swift + /// let store = TestStore( + /// initialState: MyFeature.State() + /// reducer: MyFeature() + /// ) + /// + /// store.dependencies.calendar = Calendar(identifier: .gregorian) + /// ``` + public var calendar: Calendar { + get { self[CalendarKey.self] } + set { self[CalendarKey.self] = newValue } + } + + private enum CalendarKey: DependencyKey { + static let liveValue = Calendar.autoupdatingCurrent + static var testValue: Calendar { + if !DependencyValues.isSetting { + XCTFail(#"Unimplemented: @Dependency(\.calendar)"#) + } + return .autoupdatingCurrent + } + } + } +#endif diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/Context.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/Context.swift new file mode 100644 index 0000000..34a63e5 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/Context.swift @@ -0,0 +1,25 @@ +extension DependencyValues { + /// The current dependency context. + /// + /// The current ``DependencyContext`` can be used to determine how dependencies are loaded by the + /// current runtime. + /// + /// It can also be overridden, for example via ``withValue(_:_:operation:)-705n``, to control how + /// dependencies will be loaded by the runtime for the duration of the override. + /// + /// ```swift + /// DependencyValues.withValue(\.context, .preview) { + /// // Dependencies accessed here default to their "preview" value + /// } + /// ``` + public var context: DependencyContext { + get { self[DependencyContextKey.self] } + set { self[DependencyContextKey.self] = newValue } + } +} + +enum DependencyContextKey: DependencyKey { + static let liveValue = DependencyContext.live + static let previewValue = DependencyContext.preview + static let testValue = DependencyContext.test +} diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/Date.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/Date.swift new file mode 100644 index 0000000..570d810 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/Date.swift @@ -0,0 +1,85 @@ +import Foundation +import XCTestDynamicOverlay + +#if swift(>=5.6) + extension DependencyValues { + /// A dependency that returns the current date. + /// + /// By default, a ``DateGenerator/live`` generator is supplied. When used from a `TestStore`, an + /// ``DateGenerator/unimplemented`` generator is supplied, unless explicitly overridden. + /// + /// You can access the current date from a feature by introducing a ``Dependency`` property + /// wrapper to the generator's ``DateGenerator/now`` property: + /// + /// ```swift + /// final class FeatureModel: ReducerProtocol { + /// @Dependency(\.date.now) var now + /// // ... + /// } + /// ``` + /// + /// To override the current date in tests, you can override the generator using + /// ``withValue(_:_:operation:)-705n``: + /// + /// ```swift + /// DependencyValues.withValue(\.date, .constant(Date(timeIntervalSince1970: 0))) { + /// // Assertions... + /// } + /// ``` + /// + /// Or, if you are using the Composable Architecture, you can override dependencies directly + /// on the `TestStore`: + /// + /// ```swift + /// let store = TestStore( + /// initialState: MyFeature.State() + /// reducer: MyFeature() + /// ) + /// + /// store.dependencies.date = .constant(Date(timeIntervalSince1970: 0)) + /// ``` + public var date: DateGenerator { + get { self[DateGeneratorKey.self] } + set { self[DateGeneratorKey.self] = newValue } + } + + private enum DateGeneratorKey: DependencyKey { + static let liveValue = DateGenerator { Date() } + static let testValue = DateGenerator { + XCTFail(#"Unimplemented: @Dependency(\.date)"#) + return Date() + } + } + } + + /// A dependency that generates a date. + /// + /// See ``DependencyValues/date`` for more information. + public struct DateGenerator: Sendable { + private var generate: @Sendable () -> Date + + /// A generator that returns a constant date. + /// + /// - Parameter now: A date to return. + /// - Returns: A generator that always returns the given date. + public static func constant(_ now: Date) -> Self { + Self { now } + } + + /// The current date. + public var now: Date { + self.generate() + } + + /// Initializes a date generator that generates a date from a closure. + /// + /// - Parameter generate: A closure that returns the current date when called. + public init(_ generate: @escaping @Sendable () -> Date) { + self.generate = generate + } + + public func callAsFunction() -> Date { + self.generate() + } + } +#endif diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/Locale.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/Locale.swift new file mode 100644 index 0000000..af24c52 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/Locale.swift @@ -0,0 +1,56 @@ +import Foundation +import XCTestDynamicOverlay + +#if swift(>=5.6) + extension DependencyValues { + /// The current locale that features should use. + /// + /// By default, the locale returned from `Locale.autoupdatingCurrent` is supplied. When used + /// from a `TestStore`, access will call to `XCTFail` when invoked, unless explicitly + /// overridden. + /// + /// You can access the current locale from a feature by introducing a ``Dependency`` property + /// wrapper to the property: + /// + /// ```swift + /// final class FeatureModel: ReducerProtocol { + /// @Dependency(\.locale) var locale + /// // ... + /// } + /// ``` + /// + /// To override the current locale in tests, use ``withValue(_:_:operation:)-705n``: + + /// ```swift + /// DependencyValues.withValue(\.locale, Locale(identifier: "en_US")) { + /// // Assertions... + /// } + /// ``` + /// + /// Or, if you are using the Composable Architecture, you can override dependencies directly + /// on the `TestStore`: + /// + /// ```swift + /// let store = TestStore( + /// initialState: MyFeature.State() + /// reducer: MyFeature() + /// ) + /// + /// store.dependencies.locale = Locale(identifier: "en_US") + /// ``` + public var locale: Locale { + get { self[LocaleKey.self] } + set { self[LocaleKey.self] = newValue } + } + + private enum LocaleKey: DependencyKey { + static let liveValue = Locale.autoupdatingCurrent + static var testValue: Locale { + if !DependencyValues.isSetting { + XCTFail(#"Unimplemented: @Dependency(\.locale)"#) + } + return .autoupdatingCurrent + } + } + } +#endif diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/MainQueue.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/MainQueue.swift new file mode 100644 index 0000000..1a5591e --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/MainQueue.swift @@ -0,0 +1,100 @@ +import RxSwift +import Foundation + +extension DependencyValues { + /// The "main" queue. + /// + /// Introduce controllable timing to your features by using the ``Dependency`` property wrapper + /// with a key path to this property. The wrapped value is a Combine scheduler with the time + /// type and options of a dispatch queue. By default, `DispatchQueue.main` will be provided, + /// with the exception of XCTest cases, in which an "unimplemented" scheduler will be provided. + /// + /// For example, you could introduce controllable timing to a Composable Architecture reducer + /// that counts the number of seconds it's onscreen: + /// + /// ``` + /// struct TimerReducer: ReducerProtocol { + /// struct State { + /// var elapsed = 0 + /// } + /// + /// enum Action { + /// case task + /// case timerTicked + /// } + /// + /// @Dependency(\.mainQueue) var mainQueue + /// + /// func reduce(into state: inout State, action: Action) -> EffectTask { + /// switch action { + /// case .task: + /// return .run { send in + /// for await _ in self.mainQueue.timer(interval: .seconds(1)) { + /// send(.timerTicked) + /// } + /// } + /// + /// case .timerTicked: + /// state.elapsed += 1 + /// return .none + /// } + /// } + /// } + /// ``` + /// + /// And you could test this reducer by overriding its main queue with a test scheduler: + /// + /// ``` + /// let mainQueue = DispatchQueue.test + /// + /// let store = TestStore( + /// initialState: TimerReducer.State() + /// reducer: TimerReducer() + /// .dependency(\.mainQueue, mainQueue.eraseToAnyScheduler()) + /// ) + /// + /// let task = store.send(.task) + /// + /// mainQueue.advance(by: .seconds(1) + /// await store.receive(.timerTicked) { + /// $0.elapsed = 1 + /// } + /// mainQueue.advance(by: .seconds(1) + /// await store.receive(.timerTicked) { + /// $0.elapsed = 2 + /// } + /// await task.cancel() + /// ``` + public var mainQueue: SchedulerType { + get { self[MainQueueKey.self] } + set { self[MainQueueKey.self] = newValue } + } + + fileprivate enum MainQueueKey: DependencyKey { + static let liveValue: SchedulerType = MainScheduler.instance + } +} + +#if DEBUG +import XCTestDynamicOverlay +extension DependencyValues.MainQueueKey: TestDependencyKey { + static let testValue: SchedulerType = UnimplementedSchedulerType() +} + +internal final class UnimplementedSchedulerType: SchedulerType { + internal var now: RxSwift.RxTime + + internal init() { + XCTFail("mainQueue is unimplemented") + self.now = Date() + } + + internal func scheduleRelative(_ state: StateType, dueTime: RxSwift.RxTimeInterval, action: @escaping (StateType) -> RxSwift.Disposable) -> RxSwift.Disposable { + unimplemented("mainQueue is unimplemented", placeholder: Disposables.create()) + } + + internal func schedule(_ state: StateType, action: @escaping (StateType) -> RxSwift.Disposable) -> RxSwift.Disposable { + unimplemented("mainQueue is unimplemented", placeholder: Disposables.create()) + } +} +#endif diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/OpenURL.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/OpenURL.swift new file mode 100644 index 0000000..21e3f3f --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/OpenURL.swift @@ -0,0 +1,81 @@ +import XCTestDynamicOverlay + +#if canImport(AppKit) + import AppKit +#endif +#if canImport(UIKit) + import UIKit +#endif +#if canImport(SwiftUI) + import SwiftUI +#endif + +#if canImport(AppKit) || canImport(UIKit) || canImport(SwiftUI) + extension DependencyValues { + /// A dependency that opens a URL. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 7, *) + public var openURL: OpenURLEffect { + get { self[OpenURLKey.self] } + set { self[OpenURLKey.self] = newValue } + } + } + + private enum OpenURLKey: DependencyKey { + static let liveValue = OpenURLEffect { url in + let stream = AsyncStream { continuation in + let task = Task { @MainActor in + #if canImport(AppKit) && !targetEnvironment(macCatalyst) + NSWorkspace.shared.open(url, configuration: .init()) { app, error in + continuation.yield(app != nil && error == nil) + continuation.finish() + } + #elseif canImport(UIKit) && !os(watchOS) + UIApplication.shared.open(url) { canOpen in + continuation.yield(canOpen) + continuation.finish() + } + #elseif canImport(SwiftUI) + if #available(watchOS 7, *) { + EnvironmentValues().openURL(url) + continuation.yield(true) + continuation.finish() + } else { + continuation.yield(false) + continuation.finish() + } + #else + continuation.yield(false) + continuation.finish() + #endif + } + continuation.onTermination = { _ in + task.cancel() + } + } + return await stream.first(where: { _ in true }) ?? false + } + static let testValue = OpenURLEffect { _ in + XCTFail(#"Unimplemented: @Dependency(\.openURL)"#) + return false + } + } + + public struct OpenURLEffect: Sendable { + private let handler: @Sendable (URL) async -> Bool + + public init(handler: @escaping @Sendable (URL) async -> Bool) { + self.handler = handler + } + + @available(watchOS, unavailable) + @discardableResult + public func callAsFunction(_ url: URL) async -> Bool { + await self.handler(url) + } + + @_disfavoredOverload + public func callAsFunction(_ url: URL) async { + _ = await self.handler(url) + } + } +#endif diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/RandomNumberGenerator.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/RandomNumberGenerator.swift new file mode 100644 index 0000000..ab0d1ad --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/RandomNumberGenerator.swift @@ -0,0 +1,101 @@ +import Foundation +import XCTestDynamicOverlay + +extension DependencyValues { + /// A dependency that yields a random number generator to a closure. + /// + /// Introduce controllable randomness to your features by using the ``Dependency`` property + /// wrapper with a key path to this property. The wrapped value is an instance of + /// ``WithRandomNumberGenerator``, which can be called with a closure to yield a random number + /// generator. (It can be called directly because it defines + /// ``WithRandomNumberGenerator/callAsFunction(_:)``, which is called when you invoke the instance + /// as you would invoke a function.) + /// + /// For example, you could introduce controllable randomness to a Composable Architecture reducer + /// that models rolling a couple dice: + /// + /// ```swift + /// struct Game: ReducerProtocol { + /// struct State { + /// var dice = (1, 1) + /// } + /// + /// enum Action { + /// case rollDice + /// } + /// + /// @Dependency(\.withRandomNumberGenerator) var withRandomNumberGenerator + /// + /// func reduce(into state: inout State, action: Action) -> Effect { + /// switch action { + /// case .rollDice: + /// self.withRandomNumberGenerator { generator in + /// state.dice.0 = Int.random(in: 1...6, using: &generator) + /// state.dice.1 = Int.random(in: 1...6, using: &generator) + /// } + /// return .none + /// } + /// } + /// } + /// ``` + /// + /// By default, a `SystemRandomNumberGenerator` will be provided to the closure, with the + /// exception of a `TestStore`, in which an unimplemented dependency will be provided that calls + /// `XCTFail`. + /// + /// To test a reducer that depends on randomness, you can override its random number generator. + /// Inject a dependency by calling ``WithRandomNumberGenerator/init(_:)`` with a random number + /// generator that offers predictable randomness. For example, you could test the dice-rolling of + /// a game's reducer by supplying a seeded random number generator as a dependency: + /// + /// ```swift + /// let store = TestStore( + /// initialState: Game.State() + /// reducer: Game() + /// ) + /// + /// store.dependencies.withRandomNumberGenerator = WithRandomNumberGenerator( + /// LCRNG(seed: 0) + /// ) + /// + /// await store.send(.rollDice) { + /// $0.dice = (1, 3) + /// } + /// ``` + public var withRandomNumberGenerator: WithRandomNumberGenerator { + get { self[WithRandomNumberGeneratorKey.self] } + set { self[WithRandomNumberGeneratorKey.self] = newValue } + } + + private enum WithRandomNumberGeneratorKey: DependencyKey { + static let liveValue = WithRandomNumberGenerator(SystemRandomNumberGenerator()) + static let testValue = WithRandomNumberGenerator(UnimplementedRandomNumberGenerator()) + } +} + +/// A dependency that yields a random number generator to a closure. +/// +/// See ``DependencyValues/withRandomNumberGenerator`` for more information. +public final class WithRandomNumberGenerator: @unchecked Sendable { + private var generator: RandomNumberGenerator + private let lock = NSLock() + + public init(_ generator: T) { + self.generator = generator + } + + public func callAsFunction(_ work: (inout RandomNumberGenerator) -> R) -> R { + self.lock.lock() + defer { self.lock.unlock() } + return work(&self.generator) + } +} + +private struct UnimplementedRandomNumberGenerator: RandomNumberGenerator { + var generator = SystemRandomNumberGenerator() + + mutating func next() -> UInt64 { + XCTFail(#"Unimplemented: @Dependency(\.withRandomNumberGenerator)"#) + return generator.next() + } +} diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/TimeZone.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/TimeZone.swift new file mode 100644 index 0000000..35371ce --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/TimeZone.swift @@ -0,0 +1,35 @@ +import Foundation +import XCTestDynamicOverlay + +#if swift(>=5.6) + extension DependencyValues { + /// The current time zone that features should use when handling dates. + /// + /// By default, the time zone returned from `TimeZone.autoupdatingCurrent` is supplied. When + /// used from a `TestStore`, access will call to `XCTFail` when invoked, unless explicitly + /// overridden: + /// + /// ```swift + /// let store = TestStore( + /// initialState: MyFeature.State() + /// reducer: MyFeature() + /// ) + /// + /// store.dependencies.timeZone = TimeZone(secondsFromGMT: 0) + /// ``` + public var timeZone: TimeZone { + get { self[TimeZoneKey.self] } + set { self[TimeZoneKey.self] = newValue } + } + + private enum TimeZoneKey: DependencyKey { + static let liveValue = TimeZone.autoupdatingCurrent + static var testValue: TimeZone { + if !DependencyValues.isSetting { + XCTFail(#"Unimplemented: @Dependency(\.timeZone)"#) + } + return .autoupdatingCurrent + } + } + } +#endif diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/URLSession.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/URLSession.swift new file mode 100644 index 0000000..666c29b --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/URLSession.swift @@ -0,0 +1,108 @@ +import Foundation +import XCTestDynamicOverlay + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +extension DependencyValues { + /// The URL session that features should use to make URL requests. + /// + /// By default, the session returned from `URLSession.shared` is supplied. When used from a + /// `TestStore`, access will call to `XCTFail` when invoked, unless explicitly overridden: + /// + /// ```swift + /// let store = TestStore( + /// initialState: MyFeature.State() + /// reducer: MyFeature() + /// ) + /// + /// let mockConfiguration = URLSessionConfiguration.ephemeral + /// mockConfiguration.protocolClasses = [MyMockURLProtocol.self] + /// store.dependencies.urlSession = URLSession(configuration: mockConfiguration) + /// ``` + /// + /// ### API client dependencies + /// + /// While it is possible to use this dependency value from more complex dependencies, like API + /// clients, we generally advise against _designing_ a dependency around a URL session. Mocking a + /// URL session's responses is a complex process that requires a lot of work that can be avoided. + /// + /// For example, instead of defining your dependency in a way that holds directly onto a URL + /// session in order to invoke it from a concrete implementation: + /// + /// ```swift + /// struct APIClient { + /// let urlSession: URLSession + /// + /// func fetchProfile() async throws -> Profile { + /// // Use URL session to make request + /// } + /// + /// func fetchTimeline() async throws -> Timeline { /* ... */ } + /// // ... + /// } + /// ``` + /// + /// Define your dependency as a lightweight _interface_ that holds onto endpoints that can be + /// individually overridden in a lightweight fashion: + /// + /// ```swift + /// struct APIClient { + /// var fetchProfile: () async throws -> Profile + /// var fetchTimeline: () async throws -> Timeline + /// // ... + /// } + /// ``` + /// + /// Then, you can extend this type with a live implementation that uses a URL session under the + /// hood: + /// + /// ```swift + /// extension APIClient { + /// static func live(urlSession: URLSession) -> Self { + /// Self( + /// fetchProfile: { + /// // Use URL session to make request + /// } + /// fetchTimeline: { /* ... */ }, + /// // ... + /// ) + /// } + /// } + /// ``` + public var urlSession: URLSession { + get { self[URLSessionKey.self] } + set { self[URLSessionKey.self] = newValue } + } + + private enum URLSessionKey: DependencyKey { + static let liveValue = URLSession.shared + static var testValue: URLSession { + if !DependencyValues.isSetting { + XCTFail(#"Unimplemented: @Dependency(\.urlSession)"#) + } + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [UnimplementedURLProtocol.self] + return URLSession(configuration: configuration) + } + } +} + +private final class UnimplementedURLProtocol: URLProtocol { + override class func canInit(with request: URLRequest) -> Bool { + true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + struct UnimplementedURLSession: Error {} + self.client?.urlProtocol(self, didFailWithError: UnimplementedURLSession()) + } + + override func stopLoading() { + } +} diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependencies/UUID.swift b/Sources/RxComposableArchitecture/Dependencies/Dependencies/UUID.swift new file mode 100644 index 0000000..d770905 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependencies/UUID.swift @@ -0,0 +1,141 @@ +import Foundation +import XCTestDynamicOverlay + +extension DependencyValues { + /// A dependency that generates UUIDs. + /// + /// Introduce controllable UUID generation to your features by using the ``Dependency`` property + /// wrapper with a key path to this property. The wrapped value is an instance of + /// ``UUIDGenerator``, which can be called with a closure to create UUIDs. (It can be called + /// directly because it defines ``UUIDGenerator/callAsFunction()``, which is called when you + /// invoke the instance as you would invoke a function.) + /// + /// For example, you could introduce controllable UUID generation to a reducer that creates to-dos + /// with unique identifiers: + /// + /// ```swift + /// struct Todo: Identifiable { + /// let id: UUID + /// var description: String = "" + /// } + /// + /// struct TodosReducer: ReducerProtocol { + /// struct State { + /// var todos: IdentifiedArrayOf = [] + /// } + /// + /// enum Action { + /// case create + /// } + /// + /// @Dependency(\.uuid) var uuid + /// + /// func reduce(into state: inout State, action: Action) -> Effect { + /// switch action { + /// case .create: + /// state.append(Todo(id: self.uuid()) + /// return .none + /// } + /// } + /// } + /// ``` + /// + /// By default, a ``UUIDGenerator/live`` generator is supplied, which returns a random UUID when + /// called by invoking `UUID.init` under the hood. When used from a `TestStore`, an + /// ``UUIDGenerator/unimplemented`` generator is supplied, which additionally calls `XCTFail` when + /// invoked. + /// + /// To test a reducer that depends on UUID generation, you can override its generator using the + /// `Reducer/dependency(_:_:)` modifier to override the underlying ``UUIDGenerator``: + /// + /// * ``UUIDGenerator/incrementing`` for reproducible UUIDs that count up from + /// `00000000-0000-0000-0000-000000000000`. + /// + /// * ``UUIDGenerator/constant(_:)`` for a generator that always returns the given UUID. + /// + /// For example, you could test the to-do-creating reducer by supplying an + /// ``UUIDGenerator/incrementing`` generator as a dependency: + /// + /// ```swift + /// let store = TestStore( + /// initialState: Todos.State() + /// reducer: Todos() + /// ) + /// + /// store.dependencies.uuid = .incrementing + /// + /// store.send(.create) { + /// $0.todos = [ + /// Todo(id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!) + /// ] + /// } + /// ``` + public var uuid: UUIDGenerator { + get { self[UUIDGeneratorKey.self] } + set { self[UUIDGeneratorKey.self] = newValue } + } + + private enum UUIDGeneratorKey: DependencyKey { + static let liveValue = UUIDGenerator { UUID() } + static let testValue = UUIDGenerator { + XCTFail(#"Unimplemented: @Dependency(\.uuid)"#) + return UUID() + } + } +} + +/// A dependency that generates a UUID. +/// +/// See ``DependencyValues/uuid`` for more information. +public struct UUIDGenerator: Sendable { + private let generate: @Sendable () -> UUID + + /// A generator that returns a constant UUID. + /// + /// - Parameter uuid: A UUID to return. + /// - Returns: A generator that always returns the given UUID. + public static func constant(_ uuid: UUID) -> Self { + Self { uuid } + } + + /// A generator that generates UUIDs in incrementing order. + /// + /// For example: + /// + /// ```swift + /// let generate = UUIDGenerator.incrementing + /// generate() // UUID(00000000-0000-0000-0000-000000000000) + /// generate() // UUID(00000000-0000-0000-0000-000000000001) + /// generate() // UUID(00000000-0000-0000-0000-000000000002) + /// ``` + public static var incrementing: Self { + let generator = IncrementingUUIDGenerator() + return Self { generator() } + } + + /// Initializes a UUID generator that generates a UUID from a closure. + /// + /// - Parameter generate: A closure that returns the current date when called. + public init(_ generate: @escaping @Sendable () -> UUID) { + self.generate = generate + } + + public func callAsFunction() -> UUID { + self.generate() + } +} + +private final class IncrementingUUIDGenerator: @unchecked Sendable { + private let lock = NSLock() + private var sequence = 0 + + func callAsFunction() -> UUID { + self.lock.lock() + defer { + self.sequence += 1 + self.lock.unlock() + } + return UUID( + uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", self.sequence))")! + } +} diff --git a/Sources/RxComposableArchitecture/Dependencies/Dependency.swift b/Sources/RxComposableArchitecture/Dependencies/Dependency.swift new file mode 100644 index 0000000..fda7005 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Dependency.swift @@ -0,0 +1,98 @@ +/// A property wrapper for accessing dependencies. +/// +/// All dependencies are stored in ``DependencyValues`` and one uses this property wrapper to +/// gain access to a particular dependency. Typically it used to provide dependencies to features +/// such as an observable object: +/// +/// ```swift +/// final class FeatureModel: ObservableObject { +/// @Dependency(\.apiClient) var apiClient +/// @Dependency(\.mainQueue) var mainQueue +/// @Dependency(\.uuid) var uuid +/// +/// // ... +/// } +/// ``` +/// +/// Or, if you are using the Composable Architecture: +/// +/// ```swift +/// struct Feature: ReducerProtocol { +/// @Dependency(\.apiClient) var apiClient +/// @Dependency(\.mainQueue) var mainQueue +/// @Dependency(\.uuid) var uuid +/// +/// // ... +/// } +/// ``` +/// +/// But it can be used in other situations too, such as a shared helper function of constructing +/// an effect that can be used from multiple reducers: +/// +/// ```swift +/// func sharedEffect() async throws -> Action { +/// @Dependency(\.apiClient) var apiClient +/// @Dependency(\.mainQueue) var mainQueue +/// +/// // ... +/// } +/// ``` +/// +/// For the complete list of dependency values provided by the library, see the properties of the +/// ``DependencyValues`` structure. For information about creating custom dependency values, +/// see the ``DependencyKey`` protocol. +@propertyWrapper +public struct Dependency: @unchecked Sendable { + // NB: Key paths do not conform to sendable and are instead diagnosed at the time of forming the + // literal. + private let keyPath: KeyPath + private let file: StaticString + private let fileID: StaticString + private let line: UInt + + /// Creates a dependency property to read the specified key path. + /// + /// Don't call this initializer directly. Instead, declare a property with the `Dependency` + /// property wrapper, and provide the key path of the dependency value that the property should + /// reflect: + /// + /// ```swift + /// final class FeatureModel: ObservableObject { + /// @Dependency(\.date) var date + /// + /// // ... + /// } + /// ``` + /// + /// - Parameter keyPath: A key path to a specific resulting value. + public init( + _ keyPath: KeyPath, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) { + self.keyPath = keyPath + self.file = file + self.fileID = fileID + self.line = line + } + + /// The current value of the dependency property. + public var wrappedValue: Value { + #if DEBUG + var currentDependency = DependencyValues.currentDependency + currentDependency.file = self.file + currentDependency.fileID = self.fileID + currentDependency.line = self.line + return DependencyValues.$currentDependency.withValue(currentDependency) { + /// To support our Bootstrap MockKit implementation + /// Here we tried first get the `Mocked Environment` from Bootstrap MockKit + /// and give fallback value to original our dependency injection when not get any mocked environment + /// + Bootstrap.get(environment: Value.self) ?? DependencyValues._current[keyPath: self.keyPath] + } + #else + return DependencyValues._current[keyPath: self.keyPath] + #endif + } +} diff --git a/Sources/RxComposableArchitecture/Dependencies/DependencyContext.swift b/Sources/RxComposableArchitecture/Dependencies/DependencyContext.swift new file mode 100644 index 0000000..42dac89 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/DependencyContext.swift @@ -0,0 +1,32 @@ +/// A context for a collection of ``DependencyValues``. +/// +/// There are three distinct contexts that dependencies can be loaded from and registered to: +/// +/// * ``live``: The default context. +/// * ``preview``: A context for Xcode previews. +/// * ``test``: A context for tests. +public enum DependencyContext: Sendable { + /// The default, "live" context for dependencies. + /// + /// This context is the default when a ``preview`` or ``test`` context is not detected. + /// + /// Dependencies accessed from a live context will use ``DependencyKey/liveValue`` to request a + /// default value. + case live + + /// A "preview" context for dependencies. + /// + /// This context is the default when run from an Xcode preview. + /// + /// Dependencies accessed from a preview context will use ``TestDependencyKey/previewValue-8u2sy`` + /// to request a default value. + case preview + + /// A "test" context for dependencies. + /// + /// This context is the default when run from an XCTest run. + /// + /// Dependencies accessed from a test context will use ``TestDependencyKey/testValue`` to request + /// a default value. + case test +} diff --git a/Sources/RxComposableArchitecture/Dependencies/DependencyKey.swift b/Sources/RxComposableArchitecture/Dependencies/DependencyKey.swift new file mode 100644 index 0000000..bd68972 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/DependencyKey.swift @@ -0,0 +1,239 @@ +import XCTestDynamicOverlay + +/// A key for accessing dependencies. +/// +/// Types conform to this protocol to extend ``DependencyValues`` with custom dependencies. It is +/// similar to SwiftUI's `EnvironmentKey` protocol, which is used to add values to +/// `EnvironmentValues`. +/// +/// `DependencyKey` has one main requirement, ``liveValue``, which must return a default value for +/// your dependency that is used when the application is run in a simulator or device. If the +/// ``liveValue`` is accessed while your feature runs in a `TestStore` a test failure will be +/// triggered. +/// +/// To add a `UserClient` dependency that can fetch and save user values can be done like so: +/// +/// ```swift +/// // The user client dependency. +/// struct UserClient { +/// var fetchUser: (User.ID) async throws -> User +/// var saveUser: (User) async throws -> Void +/// } +/// // Conform to DependencyKey to provide a live implementation of +/// // the interface. +/// extension UserClient: DependencyKey { +/// static let liveValue = Self( +/// fetchUser: { /* Make request to fetch user */ }, +/// saveUser: { /* Make request to save user */ } +/// ) +/// } +/// // Register the dependency within DependencyValues. +/// extension DependencyValues { +/// var userClient: UserClient { +/// get { self[UserClient.self] } +/// set { self[UserClient.self] = newValue } +/// } +/// } +/// ``` +/// +/// When a dependency is first accessed its value is cached so that it will not be requested again. +/// This means if your `liveValue` is implemented as a computed property instead of a `static let`, +/// then it will only be called a single time: +/// +/// ```swift +/// extension UserClient: DependencyKey { +/// static var liveValue: Self { +/// // Only called once when dependency is first accessed. +/// return Self(…) +/// } +/// } +/// ``` +/// +/// `DependencyKey` inherits from ``TestDependencyKey``, which has two other overridable +/// requirements: ``TestDependencyKey/testValue``, which should return a default value for the +/// purpose of testing, and ``TestDependencyKey/previewValue-8u2sy``, which can return a default +/// value suitable for Xcode previews. When left unimplemented, these endpoints will return the +/// ``liveValue``, instead. +/// +/// If you plan on separating your interface from your live implementation, conform to +/// ``TestDependencyKey`` in your interface module, and conform to `DependencyKey` in your +/// implementation module. +public protocol DependencyKey: TestDependencyKey { + /// The live value for the dependency key. + /// + /// This is the value used by default when running the application in a simulator or on a device. + /// Using a live dependency in a test context will lead to a test failure as you should mock + /// your dependencies for tests. + /// + /// To automatically supply a test dependency in a test context, consider implementing the + /// ``testValue-535kh`` requirement. + static var liveValue: Value { get } + + // NB: The associated type and requirements of TestDependencyKey are repeated in this protocol + // due to a Swift compiler bug that prevents it from inferring the associated type in + // in the base protocol. See this issue for more information: + // https://github.com/apple/swift/issues/61077 + + /// The associated type representing the type of the dependency key's value. + associatedtype Value = Self + + /// The preview value for the dependency key. + /// + /// This value is automatically used when the associated dependency value is accessed from an + /// Xcode preview, as well as when the current ``DependencyValues/context`` is set to + /// ``DependencyContext/preview``: + /// + /// ```swift + /// DependencyValues.withValues { + /// $0.context = .preview + /// } operation: { + /// // Dependencies accessed here default to their "preview" value + /// } + /// ``` + static var previewValue: Value { get } + + /// The test value for the dependency key. + /// + /// This value is automatically used when the associated dependency value is accessed from an + /// XCTest run, as well as when the current ``DependencyValues/context`` is set to + /// ``DependencyContext/test``: + /// + /// ```swift + /// DependencyValues.withValues { + /// $0.context = .test + /// } operation: { + /// // Dependencies accessed here default to their "test" value + /// } + /// ``` + static var testValue: Value { get } +} + +/// A key for accessing test dependencies. +/// +/// This protocol lives one layer below ``DependencyKey`` and allows you to separate a dependency's +/// interface from its live implementation. +/// +/// ``TestDependencyKey`` has one main requirement, ``testValue``, which must return a default value +/// for the purposes of testing, and one optional requirement, ``previewValue-8u2sy``, which can +/// return a default value suitable for Xcode previews, or the ``testValue``, if left unimplemented. +/// +/// See ``DependencyKey`` to define a static, default value for the live application. +public protocol TestDependencyKey { + /// The associated type representing the type of the dependency key's value. + associatedtype Value = Self + + // NB: This associated type should be constrained to `Sendable` when this bug is fixed: + // https://github.com/apple/swift/issues/60649 + + /// The preview value for the dependency key. + /// + /// This value is automatically used when the associated dependency value is accessed from an + /// Xcode preview, as well as when the current ``DependencyValues/context`` is set to + /// ``DependencyContext/preview``: + /// + /// ```swift + /// DependencyValues.withValues { + /// $0.context = .preview + /// } operation: { + /// // Dependencies accessed here default to their "preview" value + /// } + /// ``` + static var previewValue: Value { get } + + /// The test value for the dependency key. + /// + /// This value is automatically used when the associated dependency value is accessed from an + /// XCTest run, as well as when the current ``DependencyValues/context`` is set to + /// ``DependencyContext/test``: + /// + /// ```swift + /// DependencyValues.withValues { + /// $0.context = .test + /// } operation: { + /// // Dependencies accessed here default to their "test" value + /// } + /// ``` + static var testValue: Value { get } +} + +extension DependencyKey { + /// A default implementation that provides the ``liveValue`` to Xcode previews. + /// + /// You may provide your own default `previewValue` in your conformance to ``TestDependencyKey``, + /// which will take precedence over this implementation. + public static var previewValue: Value { Self.liveValue } + + /// A default implementation that provides the ``liveValue`` to XCTest runs, but will trigger test + /// failure to occur if accessed. + /// + /// To prevent test failures, explicitly override the dependency in any tests in which it is + /// accessed: + /// + /// ```swift + /// func testFeatureThatUsesMyDependency() { + /// DependencyValues.withValues { + /// $0.myDependency = .mock // Override dependency + /// } operation: { + /// // Test feature with dependency overridden + /// } + /// } + /// ``` + /// + /// You may provide your own default `testValue` in your conformance to ``TestDependencyKey``, + /// which will take precedence over this implementation. + public static var testValue: Value { + var dependencyDescription = "" + if let fileID = DependencyValues.currentDependency.fileID, + let line = DependencyValues.currentDependency.line + { + dependencyDescription.append( + """ + Location: + \(fileID):\(line) + + """ + ) + } + dependencyDescription.append( + Self.self == Value.self + ? """ + Dependency: + \(dependencyTypeName(Value.self)) + """ + : """ + Key: + \(dependencyTypeName(Self.self)) + Value: + \(dependencyTypeName(Value.self)) + """ + ) + // TODO: Make this error message configurable to avoid TCA-specific language outside of TCA? + XCTFail( + """ + \(DependencyValues.currentDependency.name.map { "@Dependency(\\.\($0))" } ?? "A dependency") \ + has no test implementation, but was accessed from a test context: + + \(dependencyDescription) + + Dependencies registered with the library are not allowed to use their default, live \ + implementations when run from tests. + + To fix, override \ + \(DependencyValues.currentDependency.name.map { "'\($0)'" } ?? "the dependency") with a mock \ + value in your test. If you are using the Composable Architecture, mutate the 'dependencies' \ + property on your 'TestStore'. Otherwise, use 'DependencyValues.withValues' to define a scope \ + for the override. If you'd like to provide a default value for all tests, implement the \ + 'testValue' requirement of the 'DependencyKey' protocol. + """ + ) + return Self.liveValue + } +} + +extension TestDependencyKey { + /// A default implementation that provides the ``testValue`` to Xcode previews. + /// + /// You may provide your own default `previewValue` in your conformance to ``TestDependencyKey``, + /// which will take precedence over this implementation. + public static var previewValue: Value { Self.testValue } +} diff --git a/Sources/RxComposableArchitecture/Dependencies/DependencyValues.swift b/Sources/RxComposableArchitecture/Dependencies/DependencyValues.swift new file mode 100644 index 0000000..0a6eea5 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/DependencyValues.swift @@ -0,0 +1,399 @@ +import Foundation +import XCTestDynamicOverlay + +/// A collection of dependencies that is globally available. +/// +/// To access a particular dependency from the collection you use the ``Dependency`` property +/// wrapper: +/// +/// ```swift +/// @Dependency(\.date) var date +/// let now = date.now +/// ``` +/// +/// To change a dependency for a well-defined scope you can use the +/// ``withValues(_:operation:file:line:)-2atnb`` method: +/// +/// ```swift +/// @Dependency(\.date) var date +/// let now = date.now +/// +/// DependencyValues.withValues { +/// $0.date = .constant(Date(timeIntervalSinceReferenceDate: 1234567890)) +/// } operation: { +/// @Dependency(\.date) var date +/// let now = date.now.timeIntervalSinceReferenceDate // 1234567890 +/// } +/// ``` +/// +/// The dependencies will be changed only for the lifetime of the `operation` scope, which can be +/// synchronous or asynchronous. +/// +/// To register a dependency with this storage, you first conform a type to ``DependencyKey``: +/// +/// ```swift +/// private enum MyValueKey: DependencyKey { +/// static let liveValue = 42 +/// } +/// ``` +/// +/// And then extend ``DependencyValues`` with a computed property that uses the key to read and +/// write to ``DependencyValues``: +/// +/// ```swift +/// extension DependencyValues { +/// get { self[MyValueKey.self] } +/// set { self[MyValueKey.self] = newValue } +/// } +/// ``` +/// +/// With those steps done you can access the dependency using the ``Dependency`` property wrapper: +/// +/// ```swift +/// @Dependency(\.myValue) var myValue +/// myValue // 42 +/// ``` +public struct DependencyValues: Sendable { + @TaskLocal public static var _current = Self() + + @TaskLocal static var isSetting = false + @TaskLocal static var currentDependency = CurrentDependency() + + private var cachedValues = CachedValues() + private var storage: [ObjectIdentifier: AnySendable] = [:] + + /// Creates a dependency values instance. + /// + /// You don't typically create an instance of ``DependencyValues`` directly. Doing so would + /// provide access only to default values. Instead, you rely on the dependency values' instance + /// that the library manages for you when you use the ``Dependency`` property wrapper. + public init() {} + + /// Updates a single dependency for the duration of a synchronous operation. + /// + /// For example, if you wanted to force the ``DependencyValues/date`` dependency to be a + /// particular date, you can do: + /// + /// ```swift + /// DependencyValues.withValue(\.date, .constant(Date(timeIntervalSince1970: 1234567890))) { + /// // References to date in here are pinned to 1234567890. + /// } + /// ``` + /// + /// See ``withValues(_:operation:)-9prz8`` to update multiple dependencies at once without nesting + /// calls to `withValue`. + /// + /// - Parameters: + /// - keyPath: A key path that indicates the property of the `DependencyValues` structure to + /// update. + /// - value: The new value to set for the item specified by `keyPath`. + /// - operation: The operation to run with the updated dependencies. + /// - Returns: The result returned from `operation`. + public static func withValue( + _ keyPath: WritableKeyPath, + _ value: Value, + operation: () throws -> R + ) rethrows -> R { + try Self.$isSetting.withValue(true) { + var dependencies = Self._current + dependencies[keyPath: keyPath] = value + return try Self.$_current.withValue(dependencies) { + try Self.$isSetting.withValue(false) { + try operation() + } + } + } + } + + /// Updates a single dependency for the duration of an asynchronous operation. + /// + /// For example, if you wanted to force the ``DependencyValues/date`` dependency to be a + /// particular date, you can do: + /// + /// ```swift + /// await DependencyValues.withValue(\.date, .constant(Date(timeIntervalSince1970: 1234567890))) { + /// // References to date in here are pinned to 1234567890. + /// } + /// ``` + /// + /// See ``withValues(_:operation:)-1oaja`` to update multiple dependencies at once without nesting + /// calls to `withValue`. + /// + /// - Parameters: + /// - keyPath: A key path that indicates the property of the `DependencyValues` structure to + /// update. + /// - value: The new value to set for the item specified by `keyPath`. + /// - operation: The operation to run with the updated dependencies. + /// - Returns: The result returned from `operation`. + public static func withValue( + _ keyPath: WritableKeyPath, + _ value: Value, + operation: () async throws -> R + ) async rethrows -> R { + try await Self.$isSetting.withValue(true) { + var dependencies = Self._current + dependencies[keyPath: keyPath] = value + return try await Self.$_current.withValue(dependencies) { + try await Self.$isSetting.withValue(false) { + try await operation() + } + } + } + } + + /// Updates the dependencies for the duration of a synchronous operation. + /// + /// Any mutations made to ``DependencyValues`` inside `updateValuesForOperation` will be visible + /// to everything executed in the operation. For example, if you wanted to force the ``date`` + /// dependency to be a particular date, you can do: + /// + /// ```swift + /// DependencyValues.withValues { + /// $0.date = .constant(Date(timeIntervalSince1970: 1234567890)) + /// } operation: { + /// // References to date in here are pinned to 1234567890. + /// } + /// ``` + /// + /// See ``withValue(_:_:operation:)-3yj9d`` to update a single dependency with a constant value. + /// + /// - Parameters: + /// - updateValuesForOperation: A closure for updating the current dependency values for the + /// duration of the operation. + /// - operation: An operation to perform wherein dependencies have been overridden. + /// - Returns: The result returned from `operation`. + public static func withValues( + _ updateValuesForOperation: (inout Self) throws -> Void, + operation: () throws -> R + ) rethrows -> R { + try Self.$isSetting.withValue(true) { + var dependencies = Self._current + try updateValuesForOperation(&dependencies) + return try Self.$_current.withValue(dependencies) { + try Self.$isSetting.withValue(false) { + try operation() + } + } + } + } + + /// Updates the dependencies for the duration of an asynchronous operation. + /// + /// Any mutations made to ``DependencyValues`` inside `updateValuesForOperation` will be visible + /// to everything executed in the operation. For example, if you wanted to force the + /// ``DependencyValues/date`` dependency to be a particular date, you can do: + /// + /// ```swift + /// await DependencyValues.withValues { + /// $0.date = .constant(Date(timeIntervalSince1970: 1234567890)) + /// } operation: { + /// // References to date in here are pinned to 1234567890. + /// } + /// ``` + /// + /// See ``withValue(_:_:operation:)-705n`` to update a single dependency with a constant value. + /// + /// - Parameters: + /// - updateValuesForOperation: A closure for updating the current dependency values for the + /// duration of the operation. + /// - operation: An operation to perform wherein dependencies have been overridden. + /// - Returns: The result returned from `operation`. + public static func withValues( + _ updateValuesForOperation: (inout Self) async throws -> Void, + operation: () async throws -> R + ) async rethrows -> R { + try await Self.$isSetting.withValue(true) { + var dependencies = Self._current + try await updateValuesForOperation(&dependencies) + return try await Self.$_current.withValue(dependencies) { + try await Self.$isSetting.withValue(false) { + try await operation() + } + } + } + } + + /// Accesses the dependency value associated with a custom key. + /// + /// Create custom dependency values by defining a key that conforms to the ``DependencyKey`` + /// protocol, and then using that key with the subscript operator of the ``DependencyValues`` + /// structure to get and set a value for that key: + /// + /// ```swift + /// private struct MyDependencyKey: DependencyKey { + /// static let testValue = "Default value" + /// } + /// + /// extension DependencyValues { + /// var myCustomValue: String { + /// get { self[MyDependencyKey.self] } + /// set { self[MyDependencyKey.self] = newValue } + /// } + /// } + /// ``` + /// + /// You use custom dependency values the same way you use system-provided values, setting a value + /// with ``withValue(_:_:operation:)-705n``, and reading values with the ``Dependency`` property + /// wrapper. + public subscript( + key: Key.Type, + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line + ) -> Key.Value where Key.Value: Sendable { + get { + guard let dependency = self.storage[ObjectIdentifier(key)]?.base as? Key.Value + else { + let context = + self.storage[ObjectIdentifier(DependencyContextKey.self)]?.base + as? DependencyContext + ?? defaultContext + + switch context { + case .live, .preview: + return self.cachedValues.value( + for: Key.self, + context: context, + file: file, + function: function, + line: line + ) + + case .test: + var currentDependency = Self.currentDependency + currentDependency.name = function + return Self.$currentDependency.withValue(currentDependency) { + self.cachedValues.value( + for: Key.self, + context: context, + file: file, + function: function, + line: line + ) + } + } + } + return dependency + } + set { + self.storage[ObjectIdentifier(key)] = AnySendable(newValue) + } + } +} + +private struct AnySendable: @unchecked Sendable { + let base: Any + + @inlinable + init(_ base: Base) { + self.base = base + } +} + +struct CurrentDependency { + var name: StaticString? + var file: StaticString? + var fileID: StaticString? + var line: UInt? +} + +private let defaultContext: DependencyContext = { + if ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" { + return .preview + } else { + return .live + } +}() + +private final class CachedValues: @unchecked Sendable { + struct CacheKey: Hashable, Sendable { + let id: ObjectIdentifier + let context: DependencyContext + } + + private let lock = NSRecursiveLock() + private var cached = [CacheKey: AnySendable]() + + func value( + for key: Key.Type, + context: DependencyContext, + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line + ) -> Key.Value { + self.lock.lock() + defer { self.lock.unlock() } + + let cacheKey = CacheKey(id: ObjectIdentifier(key), context: context) + guard let value = self.cached[cacheKey]?.base as? Key.Value + else { + let value: Key.Value? + switch context { + case .live: + value = _liveValue(key) as? Key.Value + case .preview: + value = Key.previewValue + case .test: + value = Key.testValue + } + + guard let value = value + else { + if !DependencyValues.isSetting { + var dependencyDescription = "" + if let fileID = DependencyValues.currentDependency.fileID, + let line = DependencyValues.currentDependency.line + { + dependencyDescription.append( + """ + Location: + \(fileID):\(line) + + """ + ) + } + dependencyDescription.append( + Key.self == Key.Value.self + ? """ + Dependency: + \(typeName(Key.Value.self)) + """ + : """ + Key: + \(typeName(Key.self)) + Value: + \(typeName(Key.Value.self)) + """ + ) + + runtimeWarn( + """ + "@Dependency(\\.\(function))" has no live implementation, but was accessed from a \ + live context. + + \(dependencyDescription) + + Every dependency registered with the library must conform to "DependencyKey", and \ + that conformance must be visible to the running application. + + To fix, make sure that "\(typeName(Key.self))" conforms to "DependencyKey" by \ + providing a live implementation of your dependency, and make sure that the \ + conformance is linked with this current application. + """, + file: DependencyValues.currentDependency.file ?? file, + line: DependencyValues.currentDependency.line ?? line + ) + } + return Key.testValue + } + + self.cached[cacheKey] = AnySendable(value) + return value + } + + return value + } +} + + +// TODO: should we have `@Dependency(\.runtimeWarningsEnabled)` and/or `@Dependency(\.treatWarningsAsErrors)`? diff --git a/Sources/RxComposableArchitecture/Dependencies/Internal/Dependencies+OpenExistential.swift b/Sources/RxComposableArchitecture/Dependencies/Internal/Dependencies+OpenExistential.swift new file mode 100644 index 0000000..c7d8500 --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Internal/Dependencies+OpenExistential.swift @@ -0,0 +1,31 @@ +#if swift(>=5.7) + // MARK: swift(>=5.7) + + // MARK: DependencyKey + + func _liveValue(_ key: Any.Type) -> Any? { + (key as? any DependencyKey.Type)?.liveValue + } +#else + // MARK: - + // MARK: swift(<5.7) + + private enum Witness {} + + // MARK: DependencyKey + + func _liveValue(_ key: Any.Type) -> Any? { + func open(_: T.Type) -> Any? { + (Witness.self as? AnyDependencyKey.Type)?.liveValue + } + return _openExistential(key, do: open) + } + + protocol AnyDependencyKey { + static var liveValue: Any { get } + } + + extension Witness: AnyDependencyKey where T: DependencyKey { + static var liveValue: Any { T.liveValue } + } +#endif diff --git a/Sources/RxComposableArchitecture/Dependencies/Internal/Dependencies+TypeName.swift b/Sources/RxComposableArchitecture/Dependencies/Internal/Dependencies+TypeName.swift new file mode 100644 index 0000000..fa6b98f --- /dev/null +++ b/Sources/RxComposableArchitecture/Dependencies/Internal/Dependencies+TypeName.swift @@ -0,0 +1,15 @@ +@usableFromInline +func dependencyTypeName(_ type: Any.Type) -> String { + var name = _typeName(type, qualified: true) + if let index = name.firstIndex(of: ".") { + name.removeSubrange(...index) + } + let sanitizedName = + name + .replacingOccurrences( + of: #"\(unknown context at \$[[:xdigit:]]+\)\."#, + with: "", + options: .regularExpression + ) + return sanitizedName +} diff --git a/Sources/RxComposableArchitecture/Effect.swift b/Sources/RxComposableArchitecture/Effect.swift index 97592f8..fd6167e 100644 --- a/Sources/RxComposableArchitecture/Effect.swift +++ b/Sources/RxComposableArchitecture/Effect.swift @@ -1,222 +1,341 @@ import Foundation import RxSwift -/// 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. +/// A type that encapsulates a unit of work that can be run in the outside world, and can feed +/// actions back to the ``Store``. /// -/// 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. +/// Effects are the perfect place to do side effects, such as network requests, saving/loading +/// from disk, creating timers, interacting with dependencies, and more. They are returned from +/// reducers so that the ``Store`` can perform the effects after the reducer is done running. /// -/// An effect simply wraps a `Publisher` value and provides some convenience initializers for -/// constructing some common types of effects. -public final class Effect: ObservableType { - public typealias Element = Output - - public let upstream: Observable - - /// Initializes an effect that wraps a publisher. Each emission of the wrapped publisher will be - /// emitted by the effect. - /// - /// This initializer is useful for turning any publisher into an effect. For example: - /// - /// Effect( - /// useCase.getData - /// .eraseToEffect() - /// ) - /// - /// - Parameter publisher: A publisher. - public init(_ observable: Observable) { - upstream = observable +/// There are 2 distinct ways to create an `Effect`: one using Swift's native concurrency tools, and +/// the other using Apple's Combine framework: +/// +/// * 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 Combine in your application, in particular for the dependencies of your feature +/// then you can create effects by making use of any of Combine's operators, and then erasing the +/// publisher type to ``Effect`` with either `eraseToEffect` or `catchToEffect`. Note that the +/// Combine interface to ``Effect`` is considered soft deprecated, and you should eventually port +/// to Swift's native concurrency tools. +/// +/// > 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 Combine 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 { + @usableFromInline + enum Operation { + case none + case observable(Observable) + case run(TaskPriority? = nil, @Sendable (Send) async -> Void) } - public func subscribe( - _ observer: Observer - ) -> Disposable where Observer: ObserverType, Element == Observer.Element { - upstream.subscribe(observer) - } + @usableFromInline + let operation: Operation - /// Initializes an effect that immediately emits the value passed in. - /// - /// - Parameter value: The value that is immediately emitted by the effect. - public convenience init(value: Output) { - self.init(Observable.just(value)) + @usableFromInline + init(operation: Operation) { + self.operation = operation } +} - /// Initializes an effect that immediately fails with the error passed in. - /// - /// - Parameter error: The error that is immediately emitted by the effect. - public convenience init(error: Error) { - self.init(Observable.error(error)) - } +// 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: Effect { - Observable.empty().eraseToEffect() + @inlinable + public static var none: Self { + Self(operation: .none) } +} - /// 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: - /// - /// 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: - /// - /// Effect.future { callback in - /// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - /// callback(.success(42)) - /// callback(.success(1729)) // Will not be emitted by the effect - /// } +// MARK: - Async/ Await Effect +extension Effect { + /// Wraps an asynchronous unit of work in 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. + /// + /// For example, if your dependency exposes an `async` function, you can use + /// ``task(priority:operation:catch:file:fileID:line:)`` to provide an asynchronous context for + /// invoking that endpoint: + /// + /// ```swift + /// struct Feature: ReducerProtocol { + /// struct State { … } + /// enum FeatureAction { + /// case factButtonTapped + /// case faceResponse(TaskResult) + /// } + /// @Dependency(\.numberFact) var numberFact + /// + /// func reduce(into state: inout State, action: Action) -> Effect { + /// switch action { + /// case .factButtonTapped: + /// return .task { [number = state.number] in + /// await .factResponse(TaskResult { try await self.numberFact.fetch(number) }) + /// } + /// + /// case .factResponse(.success(fact)): + /// // do something with fact + /// + /// case .factResponse(.failure): + /// // handle error + /// + /// ... /// } - /// - /// If you need to deliver more than one value to the effect, you should use the `Effect` - /// initializer that accepts a `Subscriber` value. - /// - /// - 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 - ) -> Effect { - Observable.create { observer in - attemptToFulfill { result in - switch result { - case let .success(output): - observer.onNext(output) - observer.onCompleted() - case let .failure(error): - observer.onError(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 -> Action, + catch handler: (@Sendable (Error) async -> Action)? = nil, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> Self { + let dependencies = DependencyValues._current + return Self( + operation: .run(priority) { send in + await DependencyValues.$_current.withValue(dependencies) { + do { + try await send(operation()) + } catch is CancellationError { + return + } catch { + guard let handler = handler else { + #if DEBUG + var errorDump = "" + customDump(error, to: &errorDump, indent: 4) + runtimeWarn( + """ + An "Effect.task" returned from "\(fileID):\(line)" threw an unhandled error. … + + \(errorDump) + + All non-cancellation errors must be explicitly handled via the "catch" parameter \ + on "Effect.task", or via a "do" block. + """, + file: file, + line: line + ) + #endif + return + } + await send(handler(error)) + } } } - - return Disposables.create() - } - .eraseToEffect() + ) } - /// Initializes an effect that lazily executes some work in the real world and synchronously sends - /// that data back into the store. + /// Wraps an asynchronous unit of work that can emit any number of times in an effect. /// - /// For example, to load a user from some JSON on the disk, one can wrap that work in an effect: + /// 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. /// - /// Effect.result { - /// let fileUrl = URL( - /// fileURLWithPath: NSSearchPathForDirectoriesInDomains( - /// .documentDirectory, .userDomainMask, true - /// )[0] - /// ) - /// .appendingPathComponent("user.json") + /// For example, if you had an async stream in a dependency client: /// - /// let result = Result { - /// let data = try Data(contentsOf: fileUrl) - /// return try JSONDecoder().decode(User.self, from: $0) - /// } + /// ```swift + /// struct EventsClient { + /// var events: () -> AsyncStream + /// } + /// ``` /// - /// return result - /// } + /// Then you could attach to it in a `run` effect by using `for await` and sending each action of + /// the stream back into the system: /// - /// - Parameter attemptToFulfill: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. - public static func result(_ attemptToFulfill: @escaping () -> Result) -> Effect { - Observable.create { observer in - switch attemptToFulfill() { - case let .success(output): - observer.onNext(output) - observer.onCompleted() - case let .failure(error): - observer.onError(error) + /// ```swift + /// case .startButtonTapped: + /// return .run { send in + /// for await event in self.events() { + /// send(.event(event)) + /// } + /// } + /// ``` + /// + /// 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 { + let dependencies = DependencyValues._current + return Self( + operation: .run(priority) { send in + await DependencyValues.$_current.withValue(dependencies) { + 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) + runtimeWarn( + """ + An "Effect.run" returned from "\(fileID):\(line)" threw an unhandled error. … + + \(errorDump) + + All non-cancellation errors must be explicitly handled via the "catch" parameter \ + on "Effect.run", or via a "do" block. + """, + file: file, + line: line + ) + #endif + return + } + await handler(error, send) + } + } } + ) + } - return Disposables.create() - } - .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. + /// + /// This effect is handy for executing some asynchronous work that your feature doesn't need to + /// react to. One such example is analytics: + /// + /// ```swift + /// case .buttonTapped: + /// return .fireAndForget { + /// try self.analytics.track("Button Tapped") + /// } + /// ``` + /// + /// 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 fireAndForget( + priority: TaskPriority? = nil, + _ work: @escaping @Sendable () async throws -> Void + ) -> Self { + Self.run(priority: priority) { _ in try? await work() } } +} - /// 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: - /// - /// Effect.run { subscriber in - /// subscriber.send(MPMediaLibrary.authorizationStatus()) - /// - /// guard MPMediaLibrary.authorizationStatus() == .notDetermined else { - /// subscriber.send(completion: .finished) - /// return AnyCancellable {} - /// } - /// - /// MPMediaLibrary.requestAuthorization { status in - /// subscriber.send(status) - /// subscriber.send(completion: .finished) - /// } - /// return AnyCancellable { - /// // Typically clean up resources that were created here, but this effect doesn't - /// // have any. - /// } - /// } - /// - /// - Parameter work: A closure that accepts a `Subscriber` value and returns a cancellable. When - /// the `Effect` is completed, the cancellable will be used to clean up any resources created - /// when the effect was started. - public static func run( - _ work: @escaping (AnyObserver) -> Disposable - ) -> Effect { - Observable.create(work).eraseToEffect() +/// 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 self.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: @MainActor (Action) -> Void + + public init(send: @escaping @MainActor (Action) -> Void) { + self.send = send } - /// Concatenates a variadic list of effects together into a single effect, which runs the effects - /// one after the other. + /// Sends an action back into the system from an effect. /// - /// - Parameter effects: A variadic list of effects. - /// - Returns: A new effect - public static func concatenate(_ effects: Effect...) -> Effect { - .concatenate(effects) + /// - Parameter action: An action. + public func callAsFunction(_ action: Action) { + guard !Task.isCancelled else { return } + self.send(action) } - /// Concatenates a collection of effects together into a single effect, which runs the effects one - /// after the other. - /// - /// - 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 } + // TODO: Daniel: Should we enable this in our fork? + // /// 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) + // } + // } +} - return - effects - .dropFirst() - .reduce(into: first) { effects, effect in - effects = effects.concat(effect).eraseToEffect() - } - } +// 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: Effect... - ) -> Effect { - .merge(effects) + @inlinable + public static func merge(_ effects: Self...) -> Self { + Self.merge(effects) } /// Merges a sequence of effects together into a single effect, which runs the effects at the same @@ -224,88 +343,255 @@ public final class Effect: ObservableType { /// /// - Parameter effects: A sequence of effects. /// - Returns: A new effect - public static func merge(_ effects: S) -> Effect where S.Element == Effect { - Observable - .merge(effects.map { $0.asObservable() }) - .eraseToEffect() + @inlinable + public static func merge(_ effects: S) -> Self where S.Element == Self { + effects.reduce(.none) { $0.merge(with: $1) } } - /// Creates an effect that executes some work in the real world that doesn't need to feed data - /// back into the store. + /// Merges this effect and another into a single effect that runs both at the same time. + /// + /// - Parameter other: Another effect. + /// - Returns: An effect that runs this effect and the other at the same time. + @inlinable + public func merge(with other: Self) -> Self { + switch (self.operation, other.operation) { + case (_, .none): + return self + case (.none, _): + return other + case (.observable, .observable), (.run, .observable), (.observable, .run): + return Self( + operation: .observable( + Observable.merge( + self.asObservable(), + other.asObservable() + ) + ) + ) + case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)): + return Self( + operation: .run { send in + await withTaskGroup(of: Void.self) { group in + group.addTask(priority: lhsPriority) { + await lhsOperation(send) + } + group.addTask(priority: rhsPriority) { + await rhsOperation(send) + } + } + } + ) + } + } + + /// Concatenates a variadic list of effects together into a single effect, which runs the effects + /// one after the other. /// - /// - Parameter work: A closure encapsulating some work to execute in the real world. - /// - Returns: An effect. - public static func fireAndForget(_ work: @escaping () -> Void) -> Effect { - Observable.deferred { - work() - return Observable.empty() + /// - Parameter effects: A variadic list of effects. + /// - Returns: A new effect + @inlinable + public static func concatenate(_ effects: Self...) -> Self { + Self.concatenate(effects) + } + + /// Concatenates a collection of effects together into a single effect, which runs the effects one + /// after the other. + /// + /// - Parameter effects: A collection of effects. + /// - Returns: A new effect + @inlinable + public static func concatenate(_ effects: C) -> Self where C.Element == Self { + effects.reduce(.none) { $0.concatenate(with: $1) } + } + + /// Concatenates this effect and another into a single effect that first runs this effect, and + /// after it completes or is cancelled, runs the other. + /// + /// - Parameter other: Another effect. + /// - Returns: An effect that runs this effect, and after it completes or is cancelled, runs the + /// other. + @inlinable + @_disfavoredOverload + public func concatenate(with other: Self) -> Self { + switch (self.operation, other.operation) { + case (_, .none): + return self + case (.none, _): + return other + case (.observable, .observable), (.run, .observable), (.observable, .run): + return Self( + operation: .observable( + Observable.concat([self.asObservable(), other.asObservable()]) + ) + ) + case let (.run(lhsPriority, lhsOperation), .run(rhsPriority, rhsOperation)): + return Self( + operation: .run { send in + if let lhsPriority = lhsPriority { + await Task(priority: lhsPriority) { await lhsOperation(send) } + .cancellableValue + } else { + await lhsOperation(send) + } + if let rhsPriority = rhsPriority { + await Task(priority: rhsPriority) { await rhsOperation(send) } + .cancellableValue + } else { + await rhsOperation(send) + } + } + ) } - .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. + /// - Parameter transform: A closure that transforms the upstream effect's action to a new action. /// - Returns: A publisher 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 { - .init(map(transform)) - } - - public func flatMap(_ transform: @escaping (Output) -> Effect) -> Effect { - .init(flatMap(transform)) + @inlinable + public func map(_ transform: @escaping (Action) -> T) -> Effect { + switch self.operation { + case .none: + return .none + case let .observable(observable): + let dependencies = DependencyValues._current + let transform = { action in + DependencyValues.$_current.withValue(dependencies) { + transform(action) + } + } + return .init(operation: .observable(observable.map(transform))) + case let .run(priority, operation): + return .init( + operation: .run(priority) { send in + await operation( + Send { action in + send(transform(action)) + } + ) + } + ) + } } } -extension ObservableType { - /// Turns any publisher into an `Effect`. - /// - /// This can be useful for when you perform a chain of publisher transformations in a reducer, and - /// you need to convert that publisher to an effect so that you can return it from the reducer: - /// - /// case .buttonTapped: - /// return fetchUser(id: 1) - /// .filter(\.isAdmin) - /// .eraseToEffect() - /// - /// - Returns: An effect that wraps `self`. - public func eraseToEffect() -> Effect { - Effect(asObservable()) - } +// MARK: - Testing Effects - /// Turns any publisher 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. - /// - /// case .buttonTapped: - /// return fetchUser(id: 1) - /// .catchToEffect() - /// .map(ProfileAction.userResponse) - /// - /// - Returns: An effect that wraps `self`. - public func catchToEffect() -> Effect> { - map(Result.success) - .catchError { Observable>.just(Result.failure($0)) } - .eraseToEffect() - } +#if DEBUG +import XCTestDynamicOverlay - /// Turns any publisher 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. - /// - /// case .buttonTapped: - /// return analyticsClient.track("Button Tapped") +extension Effect { + /// An effect that causes a test to fail if it runs. + /// + /// > Important: This Combine-based interface has been soft-deprecated in favor of Swift + /// > concurrency. Prefer using async functions and `AsyncStream`s directly in your dependencies, + /// > and using `XCTUnimplemented` from the [XCTest Dynamic Overlay](gh-xctest-dynamic-overlay) + /// > library to stub in a function that fails when invoked: + /// > + /// > ```swift + /// > struct NumberFactClient { + /// > var fetch: (Int) async throws -> String + /// > } + /// > + /// > extension NumberFactClient { + /// > static let unimplemented = Self( + /// > fetch: XCTUnimplemented( + /// > "\(Self.self).fetch", + /// > placeholder: "Not an interesting number." + /// > ) + /// > } + /// > } + /// > ``` + /// + /// 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 = AnyReducer< + /// CounterState, CounterAction, CounterEnvironment + /// > { state, action, environment in + /// switch action { + /// case .decrementButtonTapped: + /// if state > 0 { + /// state.count -= 0 + /// return .none + /// } else { + /// return environment.playAlertSound() /// .fireAndForget() + /// } /// - /// - Returns: An effect that never produces output or errors. - public func fireAndForget( - outputType _: NewOutput.Type = NewOutput.self - ) -> Effect { - return flatMap { _ in Observable.empty() } - .eraseToEffect() + /// 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. + /// + /// [gh-xctest-dynamic-overlay]: http://github.com/pointfreeco/xctest-dynamic-overlay + /// + /// - 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. + @available( + iOS, deprecated: 9999.0, message: "Call 'XCTUnimplemented' from your dependencies, instead." + ) + public static func unimplemented(_ prefix: String) -> Self { + .fireAndForget { + XCTFail("\(prefix.isEmpty ? "" : "\(prefix) - ")An unimplemented effect ran.") + } } } +#endif diff --git a/Sources/RxComposableArchitecture/Effects/Animation.swift b/Sources/RxComposableArchitecture/Effects/Animation.swift new file mode 100644 index 0000000..9dc4717 --- /dev/null +++ b/Sources/RxComposableArchitecture/Effects/Animation.swift @@ -0,0 +1,71 @@ +import RxSwift +import SwiftUI + +extension Effect { + /// Wraps the emission of each element with SwiftUI's `withAnimation`. + /// + /// ```swift + /// case .buttonTapped: + /// return .task { + /// .activityResponse(await self.apiClient.fetchActivity()) + /// } + /// .animation() + /// ``` + /// + /// - Parameter animation: An animation. + /// - Returns: A publisher. + public func animation(_ animation: Animation? = .default) -> Self { + switch self.operation { + case .none: + return .none + case let .observable(observable): + return Self( + operation: .observable( + AnimatedPublisher(upstream: observable, animation: animation).asObservable() + ) + ) + case let .run(priority, operation): + return Self( + operation: .run(priority) { send in + await operation( + Send { value in + withAnimation(animation) { + send(value) + } + } + ) + } + ) + } + } +} + +private struct AnimatedPublisher: ObservableType { + public typealias Element = Upstream.Element + + var upstream: Upstream + var animation: Animation? + + func subscribe(_ observer: Observer) -> Disposable where Observer : ObserverType, Element == Observer.Element { + let conduit = Subscriber(downstream: observer, animation: self.animation) + return self.upstream.subscribe(conduit) + } + + private final class Subscriber: ObserverType { + public typealias Element = Downstream.Element + + let downstream: Downstream + let animation: Animation? + + init(downstream: Downstream, animation: Animation?) { + self.downstream = downstream + self.animation = animation + } + + func on(_ event: RxSwift.Event) { + withAnimation { + self.downstream.on(event) + } + } + } +} diff --git a/Sources/RxComposableArchitecture/Effects/Cancellation.swift b/Sources/RxComposableArchitecture/Effects/Cancellation.swift index e99ad67..ba81325 100644 --- a/Sources/RxComposableArchitecture/Effects/Cancellation.swift +++ b/Sources/RxComposableArchitecture/Effects/Cancellation.swift @@ -5,22 +5,23 @@ extension Effect { /// Turns an effect into one that is capable of being canceled. /// /// 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: + /// ``Effect/cancel(id:)-70e00`` 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: /// - /// struct LoadUserId: Hashable {} + /// ```swift + /// struct LoadUserID {} /// - /// case .reloadButtonTapped: - /// // Start a new effect to load the user - /// return environment.loadUser - /// .map(Action.userResponse) - /// .cancellable(id: LoadUserId(), cancelInFlight: true) + /// case .reloadButtonTapped: + /// // Start a new effect to load the user + /// return self.apiClient.loadUser() + /// .map(Action.userResponse) + /// .cancellable(id: LoadUserID.self, cancelInFlight: true) /// - /// case .cancelButtonTapped: - /// // Cancel any in-flight requests to load the user - /// return .cancel(id: LoadUserId()) + /// case .cancelButtonTapped: + /// // Cancel any in-flight requests to load the user + /// return .cancel(id: LoadUserID.self) + /// ``` /// /// - Parameters: /// - id: The effect's identifier. @@ -28,57 +29,74 @@ 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) -> Effect { - Observable.deferred { - cancellablesLock.lock() - defer { cancellablesLock.unlock() } - - let id = CancelToken(id: id) - if cancelInFlight { - cancellationCancellables[id]?.forEach { $0.dispose() } - } - - /// Flag is used to prevent disposable to send event on disposed `cancellationSubject` - /// Thanks: https://github.com/dannyhertz/rxswift-composable-architecture/issues/5 - var hasCompleted = false - - let cancellationSubject = PublishSubject() - - var cancellationCancellable: AnyDisposable! - cancellationCancellable = AnyDisposable( - Disposables.create { - cancellablesLock.sync { - if !hasCompleted { - cancellationSubject.onNext(()) - cancellationSubject.onCompleted() + switch self.operation { + case .none: + return .none + case let .observable(observable): + return Self( + operation: .observable( + Observable.deferred { + _cancellablesLock.lock() + defer { _cancellablesLock.unlock() } + + let id = _CancelToken(id: id) + if cancelInFlight { + _cancellationCancellables[id]?.forEach { $0.dispose() } } - cancellationCancellables[id]?.remove(cancellationCancellable) - if cancellationCancellables[id]?.isEmpty == .some(true) { - cancellationCancellables[id] = nil - } - } - } - ) - - return self.takeUntil(cancellationSubject) - .do( - onError: { _ in - hasCompleted = true - cancellationCancellable.dispose() - }, - onSubscribed: { - _ = cancellablesLock.sync { - cancellationCancellables[id, default: []].insert( - cancellationCancellable + + /// Flag is used to prevent disposable to send event on disposed `cancellationSubject` + /// Thanks: https://github.com/dannyhertz/rxswift-composable-architecture/issues/5 + var hasCompleted = false + + let cancellationSubject = PublishSubject() + + var cancellationCancellable: AnyDisposable! + cancellationCancellable = AnyDisposable( + Disposables.create { + _cancellablesLock.sync { + if !hasCompleted { + cancellationSubject.onNext(()) + cancellationSubject.onCompleted() + } + _cancellationCancellables[id]?.remove(cancellationCancellable) + if _cancellationCancellables[id]?.isEmpty == .some(true) { + _cancellationCancellables[id] = nil + } + } + } + ) + + return observable.takeUntil(cancellationSubject) + .do( + onError: { _ in + hasCompleted = true + cancellationCancellable.dispose() + }, + onSubscribed: { + _ = _cancellablesLock.sync { + _cancellationCancellables[id, default: []].insert( + cancellationCancellable + ) + } + }, + onDispose: { + hasCompleted = true + cancellationCancellable.dispose() + } ) - } - }, - onDispose: { - hasCompleted = true - cancellationCancellable.dispose() } ) + ) + + case let .run(priority, operation): + return Self( + operation: .run(priority, { send in + await withTaskCancellation(id: id, cancelInFlight: cancelInFlight, operation: { + await operation(send) + }) + }) + ) } - .eraseToEffect() } /// Turns an effect into one that is capable of being canceled. @@ -91,7 +109,7 @@ extension Effect { /// - cancelInFlight: Determines if any in-flight effect with the same identifier should be /// canceled before starting this new one. /// - Returns: A new effect that is capable of being canceled by an identifier. - public func cancellable(id: Any.Type, cancelInFlight: Bool = false) -> Effect { + public func cancellable(id: Any.Type, cancelInFlight: Bool = false) -> Self { self.cancellable(id: ObjectIdentifier(id), cancelInFlight: cancelInFlight) } @@ -100,10 +118,10 @@ extension Effect { /// - Parameter id: An effect identifier. /// - Returns: A new effect that will cancel any currently in-flight effect with the given /// identifier. - public static func cancel(id: AnyHashable) -> Effect { - return .fireAndForget { - cancellablesLock.sync { - cancellationCancellables[CancelToken(id: id)]?.forEach { $0.dispose() } + public static func cancel(id: AnyHashable) -> Self { + .fireAndForget { + _cancellablesLock.sync { + _cancellationCancellables[_CancelToken(id: id)]?.forEach { $0.dispose() } } } } @@ -116,7 +134,7 @@ extension Effect { /// - Parameter id: A unique type identifying the effect. /// - Returns: A new effect that will cancel any currently in-flight effect with the given /// identifier. - public static func cancel(id: Any.Type) -> Effect { + public static func cancel(id: Any.Type) -> Self { .cancel(id: ObjectIdentifier(id)) } @@ -125,7 +143,7 @@ extension Effect { /// - Parameter ids: An array of effect identifiers. /// - Returns: A new effect that will cancel any currently in-flight effects with the given /// identifiers. - public static func cancel(ids: [AnyHashable]) -> Effect { + public static func cancel(ids: [AnyHashable]) -> Self { .merge(ids.map(Effect.cancel(id:))) } @@ -137,20 +155,203 @@ extension Effect { /// - Parameter ids: An array of unique types identifying the effects. /// - Returns: A new effect that will cancel any currently in-flight effects with the given /// identifiers. - public static func cancel(ids: [Any.Type]) -> Effect { + public static func cancel(ids: [Any.Type]) -> Self { .merge(ids.map(Effect.cancel(id:))) } } -struct CancelToken: Hashable { + +#if swift(>=5.7) + /// 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. + /// + /// ``` + /// enum CancelID.self {} + /// + /// await withTaskCancellation(id: CancelID.self) { + /// // ... + /// } + /// ``` + /// + /// ### Debouncing tasks + /// + /// When paired with a clock, this function can be used to debounce a unit of async work by + /// specifying the `cancelInFlight`, which will automatically cancel any in-flight work with the + /// same identifier: + /// + /// ```swift + /// @Dependency(\.continuousClock) var clock + /// enum CancelID {} + /// + /// // ... + /// + /// return .task { + /// await withTaskCancellation(id: CancelID.self, cancelInFlight: true) { + /// try await self.clock.sleep(for: .seconds(0.3)) + /// return await .debouncedResponse( + /// TaskResult { try await environment.request() } + /// ) + /// } + /// } + /// ``` + /// + /// - 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. + @_unsafeInheritExecutor + public func withTaskCancellation( + id: AnyHashable, + cancelInFlight: Bool = false, + operation: @Sendable @escaping () async throws -> T + ) async rethrows -> T { + let id = _CancelToken(id: id) + let (cancellable, task) = _cancellablesLock.sync { () -> (AnyDisposable, Task) in + if cancelInFlight { + _cancellationCancellables[id]?.forEach { $0.dispose() } + } + let task = Task { try await operation() } + let cancellable = AnyDisposable { task.cancel() } + _cancellationCancellables[id, default: []].insert(cancellable) + return (cancellable, task) + } + defer { + _cancellablesLock.sync { + _cancellationCancellables[id]?.remove(cancellable) + if _cancellationCancellables[id]?.isEmpty == .some(true) { + _cancellationCancellables[id] = nil + } + } + } + do { + return try await task.cancellableValue + } catch { + return try Result.failure(error)._rethrowGet() + } + } +#else + public func withTaskCancellation( + id: AnyHashable, + cancelInFlight: Bool = false, + operation: @Sendable @escaping () async throws -> T + ) async rethrows -> T { + let id = _CancelToken(id: id) + let (cancellable, task) = _cancellablesLock.sync { () -> (AnyDisposable, Task) in + if cancelInFlight { + _cancellationCancellables[id]?.forEach { $0.dispose() } + } + let task = Task { try await operation() } + let cancellable = AnyDisposable { task.cancel() } + _cancellationCancellables[id, default: []].insert(cancellable) + return (cancellable, task) + } + defer { + _cancellablesLock.sync { + _cancellationCancellables[id]?.remove(cancellable) + if _cancellationCancellables[id]?.isEmpty == .some(true) { + _cancellationCancellables[id] = nil + } + } + } + do { + return try await task.cancellableValue + } catch { + return try Result.failure(error)._rethrowGet() + } + } +#endif + +#if swift(>=5.7) + /// 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. + @_unsafeInheritExecutor + 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 + ) + } +#else + 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 + ) + } +#endif + +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: ID) { + _cancellablesLock.sync { _cancellationCancellables[_CancelToken(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) { + self.cancel(id: ObjectIdentifier(id)) + } +} + +@_spi(Internals) public struct _CancelToken: Hashable { let id: AnyHashable let discriminator: ObjectIdentifier - init(id: AnyHashable) { + public init(id: AnyHashable) { self.id = id self.discriminator = ObjectIdentifier(type(of: id.base)) } } -internal var cancellationCancellables: [CancelToken: Set] = [:] -internal let cancellablesLock = NSRecursiveLock() +@_spi(Internals) public var _cancellationCancellables: [_CancelToken: Set] = [:] +@_spi(Internals) public let _cancellablesLock = NSRecursiveLock() + +@rethrows +private protocol _ErrorMechanism { + associatedtype Output + func get() throws -> Output +} + +extension _ErrorMechanism { + func _rethrowError() rethrows -> Never { + _ = try _rethrowGet() + fatalError() + } + + func _rethrowGet() rethrows -> Output { + return try get() + } +} + +extension Result: _ErrorMechanism {} diff --git a/Sources/RxComposableArchitecture/Effects/ConcurrencySupport.swift b/Sources/RxComposableArchitecture/Effects/ConcurrencySupport.swift new file mode 100644 index 0000000..d0bd022 --- /dev/null +++ b/Sources/RxComposableArchitecture/Effects/ConcurrencySupport.swift @@ -0,0 +1,422 @@ +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 as a dependency client that returns an `AsyncStream`: + /// + /// ```swift + /// struct ScreenshotsClient { + /// var screenshots: () -> AsyncStream + /// func callAsFunction() -> AsyncStream { self.screenshots() } + /// } + /// ``` + /// + /// The "live" implementation of the dependency can supply a stream by erasing the appropriate + /// `NotificationCenter.Notifications` async sequence: + /// + /// ```swift + /// extension ScreenshotsClient { + /// static let live = Self( + /// 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 screenshots = AsyncStream.streamWithContinuation() + /// + /// let store = TestStore( + /// initialState: Feature.State(), + /// reducer: Feature() + /// ) + /// + /// store.dependencies.screenshots.screenshots = { screenshots.stream } + /// + /// screenshots.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: Feature.State(), + /// reducer: Feature() + /// ) + /// + /// store.dependencies.notifications = { notifications.stream } + /// + /// await store.send(.task) + /// notifications.continuation.yield("Hello") // Simulate notification being posted + /// 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 `AsyncThrowingStream` 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: Feature.State(), + /// reducer: Feature() + /// ) + /// + /// store.dependencies.notifications = { notifications.stream } + /// + /// await store.send(.task) + /// notifications.continuation.yield("Hello") // Simulate a notification being posted + /// 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 +/// @Dependency(\.analytics) var analytics +/// +/// func reduce(into state: inout State, action: Action) -> Effect { +/// switch action { +/// case .buttonTapped: +/// return .fireAndForget { try await self.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 +/// @MainActor +/// func testAnalytics() async { +/// let store = TestStore(…) +/// +/// let events = ActorIsolated<[String]>([]) +/// store.dependencies.analytics = AnalyticsClient( +/// track: { event in +/// await events.withValue { $0.append(event) } +/// } +/// ) +/// +/// await store.send(.buttonTapped) +/// +/// await events.withValue { XCTAssertEqual($0, ["Button Tapped"]) } +/// } +/// ``` +@dynamicMemberLookup +public final actor ActorIsolated { + /// The actor-isolated value. + public var value: Value + + /// Initializes actor-isolated state around a value. + /// + /// - Parameter value: A value to isolate in an actor. + 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. + /// + /// Useful for inspecting an actor-isolated value for a test assertion: + /// + /// ```swift + /// let didOpenSettings = ActorIsolated(false) + /// store.dependencies.openSettings = { await didOpenSettings.setValue(true) } + /// + /// await store.send(.settingsButtonTapped) + /// + /// await didOpenSettings.withValue { XCTAssertTrue($0) } + /// ``` + /// + /// - 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) throws -> T + ) rethrows -> T { + var value = self.value + defer { self.value = value } + return try operation(&value) + } + + /// Overwrite the isolated value with a new value. + /// + /// Useful for setting an actor-isolated value when a tested dependency runs. + /// + /// ```swift + /// let didOpenSettings = ActorIsolated(false) + /// store.dependencies.openSettings = { await didOpenSettings.setValue(true) } + /// + /// await store.send(.settingsButtonTapped) + /// + /// await didOpenSettings.withValue { XCTAssertTrue($0) } + /// ``` + /// + /// - 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 `@preconcurrency 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 { + /// The unchecked value. + 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] } + } +} + +extension UncheckedSendable: Equatable where Value: Equatable {} +extension UncheckedSendable: Hashable where Value: Hashable {} + +extension UncheckedSendable: Decodable where Value: Decodable { + public init(from decoder: Decoder) throws { + do { + let container = try decoder.singleValueContainer() + self.init(wrappedValue: try container.decode(Value.self)) + } catch { + self.init(wrappedValue: try Value(from: decoder)) + } + } +} + +extension UncheckedSendable: Encodable where Value: Encodable { + public func encode(to encoder: Encoder) throws { + do { + var container = encoder.singleValueContainer() + try container.encode(self.wrappedValue) + } catch { + try self.wrappedValue.encode(to: encoder) + } + } +} diff --git a/Sources/RxComposableArchitecture/Effects/Debouncing.swift b/Sources/RxComposableArchitecture/Effects/Debouncing.swift deleted file mode 100644 index 31454fb..0000000 --- a/Sources/RxComposableArchitecture/Effects/Debouncing.swift +++ /dev/null @@ -1,56 +0,0 @@ -import Foundation -import RxSwift - -/// Turns an effect into one that can be debounced. -/// -/// To turn an effect into a debounce-able one you must provide an identifier, which is used to -/// determine which in-flight effect should be canceled in order to start a new effect. 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 that conforms to `Hashable`, such as an empty -/// struct: -/// -/// case let .textChanged(text): -/// struct SearchId: Hashable {} -/// -/// return environment.search(text) -/// .map(Action.searchResponse) -/// .debounce(id: SearchId(), for: 0.5, scheduler: environment.mainQueue) -/// -/// - Parameters: -/// - id: The effect's identifier. -/// - dueTime: The duration you want to debounce for. -/// scheduler: The scheduler you want to deliver the debounced output to. -/// - Returns: An effect that publishes events only after a specified time elapses. - -extension Effect { - public func debounce( - id: AnyHashable, - for dueTime: RxTimeInterval, - scheduler: SchedulerType - ) -> Effect { - Observable.just(()) - .delay(dueTime, scheduler: scheduler) - .flatMap { self.observeOn(scheduler) } - .eraseToEffect() - .cancellable(id: id, cancelInFlight: true) - } - - /// Turns an effect into one that can be debounced. - /// - /// A convenience for calling ``Effect/debounce(id:for:scheduler:options:)-76yye`` with a static - /// type as the effect's unique identifier. - /// - /// - Parameters: - /// - id: A unique type identifying the effect. - /// - dueTime: The duration you want to debounce for. - /// - scheduler: The scheduler you want to deliver the debounced output to. - /// - options: Scheduler options that customize the effect's delivery of elements. - /// - Returns: An effect that publishes events only after a specified time elapses. - public func debounce( - id: Any.Type, - for dueTime: RxTimeInterval, - scheduler: SchedulerType - ) -> Effect { - self.debounce(id: ObjectIdentifier(id), for: dueTime, scheduler: scheduler) - } -} diff --git a/Sources/RxComposableArchitecture/Effects/FireAndForget.swift b/Sources/RxComposableArchitecture/Effects/FireAndForget.swift deleted file mode 100644 index 5b8a31d..0000000 --- a/Sources/RxComposableArchitecture/Effects/FireAndForget.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import RxSwift - -extension ObservableType { - public static func fireAndForget(_ work: @escaping () -> Void) -> Observable { - return Observable.deferred { () -> Observable in - work() - return .empty() - } - } -} - -extension ObservableType where Element == Never { - public func fireAndForget() -> Observable { - func absurd(_: Never) -> A {} - return map(absurd) - } -} diff --git a/Sources/RxComposableArchitecture/Effects/Observable.swift b/Sources/RxComposableArchitecture/Effects/Observable.swift new file mode 100644 index 0000000..3d4d795 --- /dev/null +++ b/Sources/RxComposableArchitecture/Effects/Observable.swift @@ -0,0 +1,348 @@ +import RxSwift + +extension Effect: ObservableType { + public typealias Element = Action + + public func subscribe(_ observer: Observer) -> Disposable where Observer : ObserverType, Action == Observer.Element { + self.observable.subscribe(observer) + } + + public var observable: Observable { + switch self.operation { + case .none: + return .empty() + case let .observable(observable): + return observable + case let .run(priority, operation): + return Observable.create { observer in + let task = Task(priority: priority) { @MainActor in + defer { observer.onCompleted() } + let send = Send { action in + observer.onNext(action) + } + await operation(send) + } + return Disposables.create { + task.cancel() + } + } + } + } + + /// Initializes an effect that wraps a publisher. + /// + /// > Important: This Combine 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-Combine + /// > interface, or by iterating over the publisher's asynchronous sequence of `values`: + /// > + /// > ```swift + /// > return .run { send in + /// > for await value in publisher.values { + /// > send(.response(value)) + /// > } + /// > } + /// > ``` + /// + /// - Parameter publisher: A publisher. + @available(iOS, deprecated: 9999.0, message: "Iterate over 'Observable.values' in an 'Effect.run', instead.") + public init>(_ observable: O) where O.Element == Action { + self.operation = .observable(observable) + } + + /// 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.") + public init(value: Action) { + self.init(Observable.just(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.") + public init(error: Error) { + self.init(operation: .observable(Observable.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 + /// } + /// } + /// ``` + /// + /// If you need to deliver more than one value to the effect, you should use the ``Effect`` + /// initializer that accepts a ``Subscriber`` value. + /// + /// - 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.") + public static func future( + _ attemptToFulfill: @escaping (@escaping (Result) -> Void) -> Void + ) -> Self { + let dependencies = DependencyValues._current + return Observable.deferred { + DependencyValues.$_current.withValue(dependencies) { + Observable.create { observer in + attemptToFulfill { result in + switch result { + case let .success(output): + observer.onNext(output) + observer.onCompleted() + case let .failure(error): + observer.onError(error) + } + } + return Disposables.create() + } + } + } + .eraseToEffect() + } + + /// 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.") + public static func result(_ attemptToFulfill: @escaping () -> Result) -> Self { + .future { $0(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 { subscriber in + /// subscriber.send(MPMediaLibrary.authorizationStatus()) + /// + /// guard MPMediaLibrary.authorizationStatus() == .notDetermined else { + /// subscriber.send(completion: .finished) + /// return AnyCancellable {} + /// } + /// + /// MPMediaLibrary.requestAuthorization { status in + /// subscriber.send(status) + /// subscriber.send(completion: .finished) + /// } + /// return AnyCancellable { + /// // Typically clean up resources that were created here, but this effect doesn't + /// // have any. + /// } + /// } + /// ``` + /// + /// - Parameter work: A closure that accepts a ``Subscriber`` value and returns a cancellable. + /// When the ``Effect`` is completed, the cancellable 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.") + public static func run( + _ work: @escaping (AnyObserver) -> Disposable + ) -> Self { + let dependencies = DependencyValues._current + return Observable.create { observer in + DependencyValues.$_current.withValue(dependencies) { + 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.") + public static func fireAndForget(_ work: @escaping () -> Void) -> Self { + let dependencies = DependencyValues._current + return Observable.deferred { + DependencyValues.$_current.withValue(dependencies) { + work() + return Observable.empty() + } + } + .eraseToEffect() + } + + public func flatMap(_ transform: @escaping (Action) -> T) -> Effect { + switch self.operation { + case let .observable(observable): + let dependencies = DependencyValues._current + let transform = { action in + DependencyValues.$_current.withValue(dependencies) { + transform(action) + } + } + return observable.flatMap(transform).eraseToEffect() + default: + return .none + } + } +} + +extension ObservableType where Element == Never { + public func fireAndForget() -> Observable { + func absurd(_: Never) -> A {} + return map(absurd) + } +} + +extension ObservableType { + /// Turns any publisher into an `Effect`. + /// + /// This can be useful for when you perform a chain of publisher transformations in a reducer, and + /// you need to convert that publisher to an effect so that you can return it from the reducer: + /// + /// case .buttonTapped: + /// return fetchUser(id: 1) + /// .filter(\.isAdmin) + /// .eraseToEffect() + /// + /// - Returns: An effect that wraps `self`. + public func eraseToEffect() -> Effect { + Effect(asObservable()) + } + + /// Turns any publisher 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 `Output` to another type. + /// - Returns: An effect that wraps `self` after mapping `Output` values. + @available(iOS, deprecated: 9999.0, message: "Iterate over 'Publisher.values' in an 'Effect.run', instead.") + public func eraseToEffect( + _ transform: @escaping (Element) -> T + ) -> Effect { + self.map(transform) + .eraseToEffect() + } + + /// Turns any publisher 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 self.apiClient.fetchUser(id: 1) + /// .catchToEffect() + /// .map(ProfileAction.userResponse) + /// ``` + /// + /// - Returns: An effect that wraps `self`. + @available(iOS, deprecated: 9999.0, message: "Iterate over 'Publisher.values' in an 'Effect.run', instead.") + public func catchToEffect() -> Effect> { + self.catchToEffect { $0 } + } + + /// Turns any publisher 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 self.apiClient.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 'Publisher.values' in an 'Effect.run', instead.") + public func catchToEffect( + _ transform: @escaping (Result) -> T + ) -> Effect { + let dependencies = DependencyValues._current + let transform = { action in + DependencyValues.$_current.withValue(dependencies) { + transform(action) + } + } + return map { transform(.success($0)) } + .catchError { Observable.just(transform(.failure($0))) } + .eraseToEffect() + } + + /// Turns any publisher 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. + /// + /// case .buttonTapped: + /// return analyticsClient.track("Button Tapped") + /// .fireAndForget() + /// + /// - Returns: An effect that never produces output or errors. + public func fireAndForget( + outputType _: NewOutput.Type = NewOutput.self + ) -> Effect { + return flatMap { _ in Observable.empty() } + .eraseToEffect() + } +} diff --git a/Sources/RxComposableArchitecture/Effects/Observable/Debouncing.swift b/Sources/RxComposableArchitecture/Effects/Observable/Debouncing.swift new file mode 100644 index 0000000..d669b17 --- /dev/null +++ b/Sources/RxComposableArchitecture/Effects/Observable/Debouncing.swift @@ -0,0 +1,69 @@ +import Foundation +import RxSwift + +extension Effect { + /// Turns an effect into one that can be debounced. + /// + /// To turn an effect into a debounce-able one you must provide an identifier, which is used to + /// determine which in-flight effect should be canceled in order to start a new effect. 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 that conforms to `Hashable`, such as an empty + /// struct: + /// + /// ```swift + /// case let .textChanged(text): + /// enum SearchID {} + /// + /// return self.apiClient.search(text) + /// .debounce(id: SearchID.self, for: 0.5, scheduler: self.mainQueue) + /// .map(Action.searchResponse) + /// ``` + /// + /// - Parameters: + /// - id: The effect's identifier. + /// - dueTime: The duration you want to debounce for. + /// - scheduler: The scheduler you want to deliver the debounced output to. + /// - options: Scheduler options that customize the effect's delivery of elements. + /// - Returns: An effect that publishes events only after a specified time elapses. + @available(iOS, deprecated: 9999.0, message: "Use 'withTaskCancellation(id: _, cancelInFlight: true)' in 'Effect.run', instead.") + public func debounce( + id: AnyHashable, + for dueTime: RxTimeInterval, + scheduler: SchedulerType + ) -> Self { + switch self.operation { + case .none: + return .none + case .observable, .run: + return Self( + operation: .observable( + Observable.just(()) + .delay(dueTime, scheduler: scheduler) + .flatMap { self.observeOn(scheduler) } + .eraseToEffect() + .cancellable(id: id, cancelInFlight: true) + .asObservable() + ) + ) + } + } + + /// Turns an effect into one that can be debounced. + /// + /// A convenience for calling ``Effect/debounce(id:for:scheduler:options:)-76yye`` with a static + /// type as the effect's unique identifier. + /// + /// - Parameters: + /// - id: A unique type identifying the effect. + /// - dueTime: The duration you want to debounce for. + /// - scheduler: The scheduler you want to deliver the debounced output to. + /// - options: Scheduler options that customize the effect's delivery of elements. + /// - Returns: An effect that publishes events only after a specified time elapses. + public func debounce( + id: Any.Type, + for dueTime: RxTimeInterval, + scheduler: SchedulerType + ) -> Self { + self.debounce(id: ObjectIdentifier(id), for: dueTime, scheduler: scheduler) + } +} diff --git a/Sources/RxComposableArchitecture/Effects/Deferring.swift b/Sources/RxComposableArchitecture/Effects/Observable/Deferring.swift similarity index 69% rename from Sources/RxComposableArchitecture/Effects/Deferring.swift rename to Sources/RxComposableArchitecture/Effects/Observable/Deferring.swift index 2970708..7c44f4e 100644 --- a/Sources/RxComposableArchitecture/Effects/Deferring.swift +++ b/Sources/RxComposableArchitecture/Effects/Observable/Deferring.swift @@ -26,10 +26,18 @@ extension Effect { public func deferred( for dueTime: RxTimeInterval, scheduler: SchedulerType - ) -> Effect { - Observable.just(()) - .delay(dueTime, scheduler: scheduler) - .flatMap { self.observeOn(scheduler) } - .eraseToEffect() + ) -> Self { + switch self.operation { + case .none: + return .none + case .observable, .run: + return Self( + operation: .observable( + Observable.just(()) + .delay(dueTime, scheduler: scheduler) + .flatMap { self.observeOn(scheduler)} + ) + ) + } } } diff --git a/Sources/RxComposableArchitecture/Effects/Throttling.swift b/Sources/RxComposableArchitecture/Effects/Observable/Throttling.swift similarity index 55% rename from Sources/RxComposableArchitecture/Effects/Throttling.swift rename to Sources/RxComposableArchitecture/Effects/Observable/Throttling.swift index 25b8201..425788e 100644 --- a/Sources/RxComposableArchitecture/Effects/Throttling.swift +++ b/Sources/RxComposableArchitecture/Effects/Observable/Throttling.swift @@ -1,6 +1,6 @@ // // File.swift -// +// // // Created by jefferson.setiawan on 05/07/22. // @@ -25,43 +25,46 @@ extension Effect { for interval: RxTimeInterval, scheduler: SchedulerType, latest: Bool - ) -> Effect { - self.observeOn(scheduler) - .flatMap { value -> Observable in - throttleLock.lock() - defer { throttleLock.unlock() } - - guard let throttleTime = throttleTimes[id] as! Date? else { - throttleTimes[id] = scheduler.now - throttleValues[id] = nil - return .just(value) - } - - let value = latest ? value : (throttleValues[id] as! Output? ?? value) - throttleValues[id] = value - guard - scheduler.now.timeIntervalSince1970 - throttleTime.timeIntervalSince1970 - < interval.convertToSecondsInterval - else { - throttleTimes[id] = scheduler.now - throttleValues[id] = nil + ) -> Self { + switch self.operation { + case .none: + return .none + case .observable, .run: + return self.observeOn(scheduler) + .flatMap { value -> Observable in + throttleLock.lock() + defer { throttleLock.unlock() } + + guard let throttleTime = throttleTimes[id] as! Date? else { + throttleTimes[id] = scheduler.now + throttleValues[id] = nil + return .just(value) + } + + let value = latest ? value : (throttleValues[id] as! Action? ?? value) + throttleValues[id] = value + guard + scheduler.now.timeIntervalSince1970 - throttleTime.timeIntervalSince1970 + < interval.convertToSecondsInterval + else { + throttleTimes[id] = scheduler.now + throttleValues[id] = nil + return .just(value) + } + let delayTimeInMs = Int((throttleTime.addingTimeInterval(interval.convertToSecondsInterval).timeIntervalSince1970 + - scheduler.now.timeIntervalSince1970) * 1_000) return .just(value) + .delay(.milliseconds(delayTimeInMs), scheduler: scheduler) + .do(onNext: { _ in + throttleLock.sync { + throttleTimes[id] = scheduler.now + throttleValues[id] = nil + } + }) } - let delayTimeInMs = Int((throttleTime.addingTimeInterval(interval.convertToSecondsInterval).timeIntervalSince1970 - - scheduler.now.timeIntervalSince1970) * 1_000) - return .just(value) - .delay( - .milliseconds(delayTimeInMs), - scheduler: scheduler - ).do(onNext: { _ in - throttleLock.sync { - throttleTimes[id] = scheduler.now - throttleValues[id] = nil - } - }) - } - .eraseToEffect() - .cancellable(id: id, cancelInFlight: true) + .eraseToEffect() + .cancellable(id: id, cancelInFlight: true) + } } /// Throttles an effect so that it only publishes one output per given interval. @@ -83,7 +86,7 @@ extension Effect { for interval: RxTimeInterval, scheduler: SchedulerType, latest: Bool - ) -> Effect { + ) -> Effect { self.throttle(id: ObjectIdentifier(id), for: interval, scheduler: scheduler, latest: latest) } } diff --git a/Sources/RxComposableArchitecture/Effects/Timer.swift b/Sources/RxComposableArchitecture/Effects/Observable/Timer.swift similarity index 62% rename from Sources/RxComposableArchitecture/Effects/Timer.swift rename to Sources/RxComposableArchitecture/Effects/Observable/Timer.swift index 4d6c75d..fc19ec9 100644 --- a/Sources/RxComposableArchitecture/Effects/Timer.swift +++ b/Sources/RxComposableArchitecture/Effects/Observable/Timer.swift @@ -7,7 +7,7 @@ import RxSwift -extension Effect where Output: RxAbstractInteger { +extension Effect where Action: RxAbstractInteger { /// Returns an effect that repeatedly emits the current time of the given scheduler on the given /// interval. /// @@ -26,65 +26,60 @@ extension Effect where Output: RxAbstractInteger { /// running your live app, but use a `TestScheduler` in tests. /// /// To start and stop a timer in your feature you can create the timer effect from an action - /// and then use the `.cancel(id:)` effect to stop the timer: - /// - /// struct AppState { - /// var count = 0 - /// } - /// - /// enum AppAction { - /// case startButtonTapped, stopButtonTapped, timerTicked - /// } - /// - /// struct AppEnvironment { - /// var mainQueue: AnySchedulerOf - /// } - /// - /// 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 } - /// - /// case .stopButtonTapped: - /// return .cancel(id: TimerId()) - /// - /// case let .timerTicked: - /// state.count += 1 - /// return .none - /// } + /// and then use the ``Effect/cancel(id:)-iun1`` effect to stop the timer: + /// + /// ```swift + /// struct Feature: ReducerProtocol { + /// struct State { var count = 0 } + /// enum Action { case startButtonTapped, stopButtonTapped, timerTicked } + /// @Dependency(\.mainQueue) var mainQueue + /// struct TimerID: Hashable {} + /// + /// func reduce(into state: inout State, action: Action) -> Effect { + /// switch action { + /// case .startButtonTapped: + /// return Effect.timer(id: TimerID(), every: 1, on: self.mainQueue) + /// .map { _ in .timerTicked } + /// + /// case .stopButtonTapped: + /// return .cancel(id: TimerID()) + /// + /// 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: /// - /// func testTimer() { - /// let scheduler = DispatchQueue.testScheduler + /// ```swift + /// @MainActor + /// func testTimer() async { + /// let mainQueue = DispatchQueue.test /// - /// let store = TestStore( - /// initialState: .init(), - /// reducer: appReducer, - /// envirnoment: .init( - /// mainQueue: scheduler.eraseToAnyScheduler() - /// ) - /// ) + /// let store = TestStore( + /// initialState: Feature.State(), + /// reducer: Feature() + /// ) /// - /// store.assert( - /// .send(.startButtonTapped), + /// store.dependencies.mainQueue = mainQueue.eraseToAnyScheduler() /// - /// .do { scheduler.advance(by: .seconds(1)) }, - /// .receive(.timerTicked) { $0.count = 1 }, + /// await store.send(.startButtonTapped) /// - /// .do { scheduler.advance(by: .seconds(5)) }, - /// .receive(.timerTicked) { $0.count = 2 }, - /// .receive(.timerTicked) { $0.count = 3 }, - /// .receive(.timerTicked) { $0.count = 4 }, - /// .receive(.timerTicked) { $0.count = 5 }, - /// .receive(.timerTicked) { $0.count = 6 }, + /// await mainQueue.advance(by: .seconds(1)) + /// await store.receive(.timerTicked) { $0.count = 1 } /// - /// .send(.stopButtonTapped) - /// ) - /// } + /// 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 } + /// + /// await store.send(.stopButtonTapped) + /// } + /// ``` /// /// - Note: This effect is only meant to be used with features built in the Composable /// Architecture, and returned from a reducer. If you want a testable alternative to @@ -97,6 +92,10 @@ extension Effect where Output: RxAbstractInteger { /// - interval: The time interval on which to publish events. For example, a value of `0.5` /// publishes an event approximately every half-second. /// - scheduler: The scheduler on which the timer runs. + /// - tolerance: The allowed timing variance when emitting events. Defaults to `nil`, which + /// allows any variance. + /// - options: Scheduler options passed to the timer. Defaults to `nil`. + @available(iOS, deprecated: 9999.0, message: "Use 'scheduler.timer' in 'Effect.run', instead.") public static func timer( id: AnyHashable, every interval: RxTimeInterval, @@ -122,11 +121,12 @@ extension Effect where Output: RxAbstractInteger { /// - tolerance: The allowed timing variance when emitting events. Defaults to `nil`, which /// allows any variance. /// - options: Scheduler options passed to the timer. Defaults to `nil`. + @available(iOS, deprecated: 9999.0, message: "Use 'scheduler.timer' in 'Effect.run', instead.") public static func timer( id: Any.Type, every interval: RxTimeInterval, on scheduler: SchedulerType - ) -> Effect { + ) -> Self { self.timer( id: ObjectIdentifier(id), every: interval, diff --git a/Sources/RxComposableArchitecture/Effects/TaskResult.swift b/Sources/RxComposableArchitecture/Effects/TaskResult.swift new file mode 100644 index 0000000..f021839 --- /dev/null +++ b/Sources/RxComposableArchitecture/Effects/TaskResult.swift @@ -0,0 +1,278 @@ +/// 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 NumberFactClient { +/// var fetch: (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 self.numberFact.fetch(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.dependencies.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.dependencies.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 == 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 + let lhsType = type(of: lhs) + if TaskResultDebugging.emitRuntimeWarnings, lhsType == type(of: rhs) { + let lhsTypeName = typeName(lhsType) + runtimeWarn( + """ + "\(lhsTypeName)" is not equatable. … + + To test two values of this type, it must conform to the "Equatable" protocol. For \ + example: + + extension \(lhsTypeName): Equatable {} + + See the documentation of "TaskResult" for more information. + """ + ) + } +#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 { + let errorType = typeName(type(of: error)) + runtimeWarn( + """ + "\(errorType)" is not hashable. … + + To hash a value of this type, it must conform to the "Hashable" protocol. For example: + + extension \(errorType): Hashable {} + + See the documentation of "TaskResult" for more information. + """ + ) + } +#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 + } +} + diff --git a/Sources/RxComposableArchitecture/Internal/AnyDisposable.swift b/Sources/RxComposableArchitecture/Internal/AnyDisposable.swift index 51075fe..9806c62 100644 --- a/Sources/RxComposableArchitecture/Internal/AnyDisposable.swift +++ b/Sources/RxComposableArchitecture/Internal/AnyDisposable.swift @@ -8,22 +8,26 @@ import Foundation import RxSwift -internal final class AnyDisposable: Disposable, Hashable { - internal let _dispose: () -> Void +public final class AnyDisposable: Disposable, Hashable { + private let _dispose: () -> Void - internal init(_ disposable: Disposable) { + public init(_ disposable: Disposable) { _dispose = disposable.dispose } + + public init(_ cancel: @escaping () -> Void) { + _dispose = cancel + } - internal func dispose() { + public func dispose() { _dispose() } - internal static func == (lhs: AnyDisposable, rhs: AnyDisposable) -> Bool { + public static func == (lhs: AnyDisposable, rhs: AnyDisposable) -> Bool { return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) } - internal func hash(into hasher: inout Hasher) { + public func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } } diff --git a/Sources/RxComposableArchitecture/Internal/Deprecated.swift b/Sources/RxComposableArchitecture/Internal/Deprecated.swift index 01ad48e..0ffb1b5 100644 --- a/Sources/RxComposableArchitecture/Internal/Deprecated.swift +++ b/Sources/RxComposableArchitecture/Internal/Deprecated.swift @@ -7,7 +7,27 @@ import Darwin -extension Reducer { +// MARK: - Deprecated after 0.42.0: + +/// This API has been soft-deprecated in favor of ``ReducerProtocol``. +/// Read for more information. +/// +/// A type alias to ``AnyReducer`` for source compatibility. This alias will be removed. +@available( + *, + deprecated, + renamed: "AnyReducer", + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ +) +public typealias Reducer = AnyReducer + + +extension AnyReducer { @available(*, deprecated, renamed: "optional()") public var optional: Reducer { self.optional() @@ -75,7 +95,7 @@ extension Reducer { } } -extension Reducer where State: HashDiffable { +extension AnyReducer where State: HashDiffable { /// https://github.com/pointfreeco/swift-composable-architecture/pull/641 @available(*, deprecated, message: "Use the 'IdentifiedArray'-based version, instead.") public func forEach( diff --git a/Sources/RxComposableArchitecture/Internal/Locking.swift b/Sources/RxComposableArchitecture/Internal/Locking.swift index 202e2a1..f333a8d 100644 --- a/Sources/RxComposableArchitecture/Internal/Locking.swift +++ b/Sources/RxComposableArchitecture/Internal/Locking.swift @@ -18,7 +18,7 @@ extension UnsafeMutablePointer where Pointee == os_unfair_lock_s { extension NSRecursiveLock { @inlinable @discardableResult - internal func sync(work: () -> R) -> R { + @_spi(Internals) public func sync(work: () -> R) -> R { lock() defer { self.unlock() } return work() diff --git a/Sources/RxComposableArchitecture/Internal/OpenExistential.swift b/Sources/RxComposableArchitecture/Internal/OpenExistential.swift new file mode 100644 index 0000000..22739ed --- /dev/null +++ b/Sources/RxComposableArchitecture/Internal/OpenExistential.swift @@ -0,0 +1,38 @@ +#if swift(>=5.7) + // MARK: swift(>=5.7) + // MARK: Equatable + + func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool? { + (lhs as? any Equatable)?.isEqual(other: rhs) + } + + extension Equatable { + fileprivate func isEqual(other: Any) -> Bool { + self == other as? Self + } + } +#else + // MARK: - + // MARK: swift(<5.7) + + private enum Witness {} + + // MARK: Equatable + + func _isEqual(_ lhs: Any, _ rhs: Any) -> Bool? { + func open(_: T.Type) -> Bool? { + (Witness.self as? AnyEquatable.Type)?.isEqual(lhs, rhs) + } + return _openExistential(type(of: lhs), do: open) + } + + extension Witness: AnyEquatable where T: Equatable { + fileprivate static func isEqual(_ lhs: Any, _ rhs: Any) -> Bool { + guard + let lhs = lhs as? T, + let rhs = rhs as? T + else { return false } + return lhs == rhs + } + } +#endif diff --git a/Sources/RxComposableArchitecture/Internal/RuntimeWarnings.swift b/Sources/RxComposableArchitecture/Internal/RuntimeWarnings.swift index 4b8ee40..ca7b9c8 100644 --- a/Sources/RxComposableArchitecture/Internal/RuntimeWarnings.swift +++ b/Sources/RxComposableArchitecture/Internal/RuntimeWarnings.swift @@ -1,52 +1,71 @@ -#if DEBUG -import os - -// NB: Xcode runtime warnings offer a much better experience than traditional assertions and -// breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. -// To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. -// -// Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc -private let rw = ( - dso: { () -> UnsafeMutableRawPointer in - let count = _dyld_image_count() - for i in 0.. StaticString, - _ args: @autoclosure () -> [CVarArg] = [] +func runtimeWarn( + _ message: @autoclosure () -> String, + category: String? = "RxComposableArchitecture", + file: StaticString? = nil, + line: UInt? = nil ) { #if DEBUG - if _XCTIsTesting { - XCTFail(String(format: "\(message())", arguments: args())) - } else { - if #available(iOS 12.0, *) { - 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()) + let message = message() + let category = category ?? "Runtime Warning" + if _XCTIsTesting { + if let file = file, let line = line { + XCTFail(message, file: file, line: line) + } else { + XCTFail(message) + } } else { - fputs( - "\(message())", - stderr - ) - raise(SIGTRAP) + #if canImport(os) + os_log( + .fault, + dso: dso, + log: OSLog(subsystem: "com.apple.runtime-issues", category: category), + "%@", + message + ) + #else + fputs("\(formatter.string(from: Date())) [\(category)] \(message)\n", stderr) + #endif } - } #endif } + +#if DEBUG + import XCTestDynamicOverlay + + #if canImport(os) + import os + + // NB: Xcode runtime warnings offer a much better experience than traditional assertions and + // breakpoints, but Apple provides no means of creating custom runtime warnings ourselves. + // To work around this, we hook into SwiftUI's runtime issue delivery mechanism, instead. + // + // Feedback filed: https://gist.github.com/stephencelis/a8d06383ed6ccde3e5ef5d1b3ad52bbc + @usableFromInline + let dso = { () -> UnsafeMutableRawPointer in + let count = _dyld_image_count() + for i in 0.. { + var wrappedValue: Wrapped + + init(wrappedValue: Wrapped) { + self.wrappedValue = wrappedValue + } + + var boxedValue: Wrapped { + _read { yield self.wrappedValue } + _modify { yield &self.wrappedValue } + } +} diff --git a/Sources/RxComposableArchitecture/Internal/TaskCancellableValue.swift b/Sources/RxComposableArchitecture/Internal/TaskCancellableValue.swift new file mode 100644 index 0000000..2e7d136 --- /dev/null +++ b/Sources/RxComposableArchitecture/Internal/TaskCancellableValue.swift @@ -0,0 +1,24 @@ +extension Task where Failure == Error { + @_spi(Internals) public var cancellableValue: Success { + get async throws { + try await withTaskCancellationHandler { + try await self.value + } onCancel: { + self.cancel() + } + } + } +} + +extension Task where Failure == Never { + @usableFromInline + var cancellableValue: Success { + get async { + await withTaskCancellationHandler { + await self.value + } onCancel: { + self.cancel() + } + } + } +} diff --git a/Sources/RxComposableArchitecture/Internal/XCTIsTesting.swift b/Sources/RxComposableArchitecture/Internal/XCTIsTesting.swift deleted file mode 100644 index 35e4019..0000000 --- a/Sources/RxComposableArchitecture/Internal/XCTIsTesting.swift +++ /dev/null @@ -1,11 +0,0 @@ -#if DEBUG -import Foundation - -public let _XCTIsTesting: Bool = { - ProcessInfo.processInfo.environment.keys.contains("XCTestSessionIdentifier") - || ProcessInfo.processInfo.arguments.first - .flatMap(URL.init(fileURLWithPath:)) - .map { $0.lastPathComponent == "xctest" || $0.pathExtension == "xctest" } - ?? false -}() -#endif diff --git a/Sources/RxComposableArchitecture/Reducer.swift b/Sources/RxComposableArchitecture/Reducer.swift deleted file mode 100644 index 1fdcfe8..0000000 --- a/Sources/RxComposableArchitecture/Reducer.swift +++ /dev/null @@ -1,468 +0,0 @@ -import CasePaths -import Darwin -import RxSwift - -public struct Reducer { - private let reducer: (inout State, Action, Environment) -> Effect - - public init(_ reducer: @escaping (inout State, Action, Environment) -> Effect) { - self.reducer = reducer - } - - public static var empty: Reducer { - Self { _, _, _ in .none } - } - - public static func combine(_ reducers: Reducer...) -> Reducer { - .combine(reducers) - } - - public static func combine(_ reducers: [Reducer]) -> Reducer { - Self { value, action, environment in - .merge(reducers.map { $0.reducer(&value, action, environment) }) - } - } - - /// Transforms a reducer that works on local state, action and environment into one that works on - /// global state, action and environment. It accomplishes this by providing 3 transformations to - /// the method: - /// - /// * A writable key path that can get/set a piece of local state from the global state. - /// * A case path that can extract/embed a local action into a global action. - /// * A function that can transform the global environment into a local environment. - /// - /// This operation is important for breaking down large reducers into small ones. When used with - /// the `combine` operator you can define many reducers that work on small pieces of domain, and - /// then _pull them back_ and _combine_ them into one big reducer that works on a large domain. - /// - /// // Global domain that holds a local domain: - /// struct AppState { var settings: SettingsState, /* rest of state */ } - /// struct AppAction { case settings(SettingsAction), /* other actions */ } - /// struct AppEnvironment { var settings: SettingsEnvironment, /* rest of dependencies */ } - /// - /// // A reducer that works on the local domain: - /// let settingsReducer = Reducer { ... } - /// - /// // Pullback the settings reducer so that it works on all of the app domain: - /// let appReducer: Reducer = .combine( - /// settingsReducer.pullback( - /// state: \.settings, - /// action: /AppAction.settings, - /// environment: { $0.settings } - /// ), - /// - /// /* other reducers */ - /// ) - /// - /// - Parameters: - /// - toLocalState: A writable path (`WritableKeyPath`, `CasePath`, or `OptionalPath`) that can - /// get/set `State` inside `GlobalState`. - /// - toLocalAction: A writable path (`WritableKeyPath`, `CasePath`, or `OptionalPath`) that can - /// get/set `Action` inside `GlobalAction`. - /// - toLocalEnvironment: A function that transforms `GlobalEnvironment` into `Environment`. - /// - Returns: A reducer that works on `GlobalState`, `GlobalAction`, `GlobalEnvironment`. - public func pullback( - state toLocalState: WritableKeyPath, - action toLocalAction: CasePath, - environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment - ) -> Reducer { - .init { globalState, globalAction, globalEnvironment in - guard let localAction = toLocalAction.extract(from: globalAction) else { return .none } - return self.reducer( - &globalState[keyPath: toLocalState], - localAction, - toLocalEnvironment(globalEnvironment) - ) - .map(toLocalAction.embed) - } - } - - public func pullback( - state toLocalState: StatePath, - action toLocalAction: ActionPath, - environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment - ) -> Reducer - where - StatePath: WritablePath, StatePath.Root == GlobalState, StatePath.Value == State, - ActionPath: WritablePath, ActionPath.Root == GlobalAction, ActionPath.Value == Action { - return .init { globalState, globalAction, globalEnvironment in - guard - var localState = toLocalState.extract(from: globalState), - let localAction = toLocalAction.extract(from: globalAction) - else { return .none } - let effect = - self - .reducer(&localState, localAction, toLocalEnvironment(globalEnvironment)) - .map { localAction -> GlobalAction in - var globalAction = globalAction - toLocalAction.set(into: &globalAction, localAction) - return globalAction - } - toLocalState.set(into: &globalState, localState) - return effect - } - } - - /// Transforms a reducer that works on non-optional state into one that works on optional state by - /// only running the non-optional reducer when state is non-nil. - /// - /// Often used in tandem with `pullback` to transform a reducer on a non-optional child domain - /// into a reducer that can be combined with a reducer on a parent domain that contains some - /// optional child domain: - /// - /// // Global domain that holds an optional local domain: - /// struct AppState { var modal: ModalState? } - /// enum AppAction { case modal(ModalAction) } - /// struct AppEnvironment { var mainQueue: AnySchedulerOf } - /// - /// // A reducer that works on the non-optional local domain: - /// let modalReducer = Reducer.combine( - /// modalReducer.optional().pullback( - /// state: \.modal, - /// action: /AppAction.modal, - /// environment: { ModalEnvironment(mainQueue: $0.mainQueue) } - /// ), - /// Reducer { state, action, environment in - /// ... - /// } - /// ) - /// - /// Take care when combining optional reducers into parent domains. An optional reducer cannot - /// process actions in its domain when its state is `nil`. If a child action is sent to an - /// optional reducer when child state is `nil`, it is generally considered a logic error. There - /// are a few ways in which these errors can sneak into a code base: - /// - /// * A parent reducer sets child state to `nil` when processing a child action and runs - /// _before_ the child reducer: - /// - /// let parentReducer = Reducer.combine( - /// // When combining reducers, the parent reducer runs first - /// Reducer { state, action, environment in - /// switch action { - /// case .child(.didDisappear): - /// // And `nil`s out child state when processing a child action - /// state.child = nil - /// return .none - /// ... - /// } - /// }, - /// // Before the child reducer runs - /// childReducer.optional().pullback(...) - /// ) - /// - /// let childReducer = Reducer< - /// ChildState, ChildAction, ChildEnvironment - /// > { state, action environment in - /// case .didDisappear: - /// // This action is never received here because child state is `nil` in the parent - /// ... - /// } - /// - /// To ensure that a child reducer can process any action that a parent may use to `nil` out - /// its state, combine it _before_ the parent: - /// - /// let parentReducer = Reducer.combine( - /// // The child runs first - /// childReducer.optional().pullback(...), - /// // The parent runs after - /// Reducer { state, action, environment in - /// ... - /// } - /// ) - /// - /// * A child effect feeds a child action back into the store when child state is `nil`: - /// - /// let childReducer = Reducer< - /// ChildState, ChildAction, ChildEnvironment - /// > { state, action environment in - /// switch action { - /// case .onAppear: - /// // An effect may want to feed its result back to the child domain in an action - /// return environment.apiClient - /// .request() - /// .map(ChildAction.response) - /// - /// case let .response(response): - /// // But the child cannot process this action if its state is `nil` in the parent - /// ... - /// } - /// } - /// - /// It is perfectly reasonable to ignore the result of an effect when child state is `nil`, - /// for example one-off effects that you don't want to cancel. However, many long-living - /// effects _should_ be explicitly canceled when tearing down a child domain: - /// - /// let childReducer = Reducer< - /// ChildState, ChildAction, ChildEnvironment - /// > { state, action environment in - /// enum MotionId {} - /// - /// switch action { - /// case .onAppear: - /// // Mark long-living effects that shouldn't outlive their domain cancellable - /// return environment.motionClient - /// .start() - /// .map(ChildAction.motion) - /// .cancellable(id: MotionId.self) - /// - /// case .onDisappear: - /// // And explicitly cancel them when the domain is torn down - /// return .cancel(id: MotionId.self) - /// ... - /// } - /// } - /// - /// * A view store sends a child action when child state is `nil`: - /// - /// WithViewStore(self.parentStore) { parentViewStore in - /// // If child state is `nil`, it cannot process this action. - /// Button("Child Action") { parentViewStore.send(.child(.action)) } - /// ... - /// } - /// - /// Use `Store.scope` with`IfLetStore` or `Store.ifLet` to ensure that views can only send - /// child actions when the child domain is non-`nil`. - /// - /// IfLetStore( - /// self.parentStore.scope(state: { $0.child }, action: { .child($0) } - /// ) { childStore in - /// // This destination only appears when child state is non-`nil` - /// WithViewStore(childStore) { childViewStore in - /// // So this action can only be sent when child state is non-`nil` - /// Button("Child Action") { childViewStore.send(.action) } - /// } - /// ... - /// } - /// - /// - See also: `IfLetStore`, a SwiftUI helper for transforming a store on optional state into a - /// store on non-optional state. - /// - See also: `Store.ifLet`, a UIKit helper for doing imperative work with a store on optional - /// state. - /// - /// - Parameter breakpointOnNil: Raises `SIGTRAP` signal when an action is sent to the reducer - /// but state is `nil`. This is generally considered a logic error, as a child reducer cannot - /// process a child action for unavailable child state. - /// - Returns: A reducer that works on optional state. - public func optional( - breakpointOnNil: Bool = true, - _ file: StaticString = #file, - _ line: UInt = #line - ) -> Reducer< - State?, Action, Environment - > { - .init { state, action, environment in - guard state != nil else { - #if DEBUG - if breakpointOnNil { - fputs( - """ - --- - Warning: Reducer.optional@\(file):\(line) - - "\(debugCaseOutput(action))" was received by an optional reducer when its state was \ - "nil". This is generally considered an application logic error, and can happen for a \ - few reasons: - - * The optional reducer was combined with or run from another reducer that set \ - "\(State.self)" to "nil" before the optional reducer ran. Combine or run optional \ - reducers before reducers that can set their state to "nil". This ensures that \ - optional reducers can handle their actions while their state is still non-"nil". - - * An in-flight effect emitted this action while state was "nil". While it may be \ - perfectly reasonable to ignore this action, you may want to cancel the associated \ - effect before state is set to "nil", especially if it is a long-living effect. - - * This action was sent to the store while state was "nil". Make sure that actions \ - for this reducer can only be sent to a view store when state is non-"nil". In \ - SwiftUI applications, use "IfLetStore". - --- - - """, - stderr - ) - raise(SIGTRAP) - } - #endif - return .none - } - return self.reducer(&state!, action, environment) - } - } - - public func forEach( - state toLocalState: WritableKeyPath, - action toLocalAction: CasePath, - environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, - breakpointOnNil: Bool = true, - _ file: StaticString = #file, - _ line: UInt = #line - ) -> Reducer { - .init { globalState, globalAction, globalEnvironment in - guard let (key, localAction) = toLocalAction.extract(from: globalAction) else { return .none } - if globalState[keyPath: toLocalState][key] == nil { - #if DEBUG - if breakpointOnNil { - fputs( - """ - --- - Warning: Reducer.forEach@\(file):\(line) - - "\(debugCaseOutput(localAction))" was received by a "forEach" reducer at key \(key) \ - when its state contained no element at this key. This is generally considered an \ - application logic error, and can happen for a few reasons: - - * This "forEach" reducer was combined with or run from another reducer that removed \ - the element at this key when it handled this action. To fix this make sure that this \ - "forEach" reducer is run before any other reducers that can move or remove elements \ - from state. This ensures that "forEach" reducers can handle their actions for the \ - element at the intended key. - - * An in-flight effect emitted this action while state contained no element at this \ - key. It may be perfectly reasonable to ignore this action, but you also may want to \ - cancel the effect it originated from when removing a value from the dictionary, \ - especially if it is a long-living effect. - - * This action was sent to the store while its state contained no element at this \ - key. To fix this make sure that actions for this reducer can only be sent to a view \ - store when its state contains an element at this key. - --- - - """, - stderr - ) - raise(SIGTRAP) - } - #endif - return .none - } - return self.reducer( - &globalState[keyPath: toLocalState][key]!, - localAction, - toLocalEnvironment(globalEnvironment) - ) - .map { toLocalAction.embed((key, $0)) } - } - } - - /// A version of `pullback` that transforms a reducer that works on an element into one that works - /// on an identified array of elements. - /// - /// // 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: AnySchedulerOf } - /// - /// // A reducer that works on a local domain: - /// let todoReducer = Reducer { ... } - /// - /// // Pullback the local todo reducer so that it works on all of the app domain: - /// let appReducer = Reducer.combine( - /// todoReducer.forEach( - /// state: \.todos, - /// action: /AppAction.todo(id:action:), - /// environment: { _ in TodoEnvironment() } - /// ), - /// Reducer { state, action, environment in - /// ... - /// } - /// ) - /// - /// 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 - /// `GlobalState`. - /// - toLocalAction: A case path that can extract/embed `(Collection.Index, Action)` from - /// `GlobalAction`. - /// - toLocalEnvironment: A function that transforms `GlobalEnvironment` into `Environment`. - /// - breakpointOnNil: Raises `SIGTRAP` signal when an action is sent to the reducer but the - /// identified array does not contain an element with the action's identifier. This is - /// generally considered a logic error, as a child reducer cannot process a child action - /// for unavailable child state. - /// - Returns: A reducer that works on `GlobalState`, `GlobalAction`, `GlobalEnvironment`. - public func forEach( - state toLocalState: WritableKeyPath>, - action toLocalAction: CasePath, - environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, - breakpointOnNil: Bool = true, - _ file: StaticString = #file, - _ line: UInt = #line - ) -> Reducer { - .init { globalState, globalAction, globalEnvironment in - guard let (id, localAction) = toLocalAction.extract(from: globalAction) else { return .none } - if globalState[keyPath: toLocalState][id: id] == nil { - #if DEBUG - if breakpointOnNil { - fputs( - """ - --- - Warning: Reducer.forEach@\(file):\(line) - - "\(debugCaseOutput(localAction))" was received by a "forEach" reducer at id \(id) \ - when its state contained no element at this id. This is generally considered an \ - application logic error, and can happen for a few reasons: - - * This "forEach" reducer was combined with or run from another reducer that removed \ - the element at this id when it handled this action. To fix this make sure that this \ - "forEach" reducer is run before any other reducers that can move or remove elements \ - from state. This ensures that "forEach" reducers can handle their actions for the \ - element at the intended id. - - * An in-flight effect emitted this action while state contained no element at this \ - id. It may be perfectly reasonable to ignore this action, but you also may want to \ - cancel the effect it originated from when removing an element from the identified \ - array, especially if it is a long-living effect. - - * This action was sent to the store while its state contained no element at this id. \ - To fix this make sure that actions for this reducer can only be sent to a view store \ - when its state contains an element at this id. In SwiftUI applications, use \ - "ForEachStore". - --- - - """, - stderr - ) - raise(SIGTRAP) - } - #endif - return .none - } - return - self - .reducer( - &globalState[keyPath: toLocalState][id: id]!, - localAction, - toLocalEnvironment(globalEnvironment) - ) - .map { toLocalAction.embed((id, $0)) } - } - } - - public func combined(with other: Reducer) -> Reducer { - .combine(self, other) - } - - /// Runs the reducer. - /// - /// - Parameters: - /// - state: Mutable state. - /// - action: An action. - /// - environment: An environment. - /// - debug: any additional action when executing reducer - /// - Returns: An effect that can emit zero or more actions. - public func run( - _ state: inout State, - _ action: Action, - _ environment: Environment - ) -> Effect { - #if DEBUG - reducer(&state, action, Bootstrap.get(environment: type(of: environment)) ?? environment) - #else - reducer(&state, action, environment) - #endif - } -} diff --git a/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducer.swift b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducer.swift new file mode 100644 index 0000000..155305b --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducer.swift @@ -0,0 +1,716 @@ +import CasePaths +import Darwin +import RxSwift + +/// This API has been soft-deprecated in favor of ``ReducerProtocol``. +/// Read for more information. +/// +/// 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. +/// +/// Reducers have 3 generics: +/// +/// * `State`: A type that holds the current state of the application. +/// * `Action`: A type that holds all possible actions that cause the state of the application to +/// change. +/// * `Environment`: A type that holds all dependencies needed in order to produce ``Effect``s, +/// such as API clients, analytics clients, random number generators, etc. +/// +/// > 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. +@available( + iOS, + deprecated: 9999.0, + message: + """ + This API has been soft-deprecated in favor of 'ReducerProtocol'. Read the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ +) +public struct AnyReducer { + private let reducer: (inout State, Action, Environment) -> Effect + + /// > This API has been soft-deprecated in favor of ``ReducerProtocol``. + /// Read for more information. + /// + /// Initializes a reducer from a simple reducer function signature. + /// + /// The reducer takes three arguments: state, action and environment. The state is `inout` so that + /// you can make any changes to it directly inline. The reducer must return an effect, which + /// typically would be constructed by using the dependencies inside the `environment` value. If + /// no effect needs to be executed, a ``Effect/none`` effect can be returned. + /// + /// For example: + /// + /// ```swift + /// struct MyState { var count = 0, text = "" } + /// enum MyAction { case buttonTapped, textChanged(String) } + /// struct MyEnvironment { var analyticsClient: AnalyticsClient } + /// + /// let myReducer = AnyReducer { state, action, environment in + /// switch action { + /// case .buttonTapped: + /// state.count += 1 + /// return environment.analyticsClient.track("Button Tapped") + /// + /// case .textChanged(let text): + /// state.text = text + /// return .none + /// } + /// } + /// ``` + /// + /// - Parameter reducer: A function signature that takes state, action and + /// environment. + @available( + iOS, + deprecated: 9999.0, + message: + """ + This API has been soft-deprecated in favor of 'ReducerProtocol'. Read the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public init(_ reducer: @escaping (inout State, Action, Environment) -> Effect) { + self.reducer = reducer + } + + public static var empty: AnyReducer { + Self { _, _, _ in .none } + } + + /// This API has been soft-deprecated in favor of combining reducers in a ``ReducerBuilder``. Read + /// for more information. + /// + /// 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: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:fileID:line:)-2ypoa`` + /// 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:fileID:line:)`` reducer with a + /// parent domain: + /// + /// ```swift + /// let parentReducer = AnyReducer.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)`. + /// AnyReducer { state, action, environment in + /// switch action + /// case .child(.dismiss): + /// state.child = nil + /// return .none + /// ... + /// } + /// }, + /// ) + /// ``` + /// + /// - Parameter reducers: A list of reducers. + /// - Returns: A single reducer. + @available( + iOS, + deprecated: 9999.0, + message: + """ + This API has been soft-deprecated in favor of combining reducers in a 'ReducerBuilder'. Read the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public static func combine(_ reducers: Self...) -> Self { + .combine(reducers) + } + + /// This API has been soft-deprecated in favor of combining reducers in a ``ReducerBuilder``. Read + /// for more information. + /// + /// Combines many reducers into a single one by running each one on state in order, and merging + /// all of the effects. + /// + /// This method is identical to ``AnyReducer/combine(_:)-94fzl`` except that it takes an array + /// of reducers instead of a variadic list. See the documentation on + /// ``AnyReducer/combine(_:)-94fzl`` for more information about what this method does. + /// + /// - Parameter reducers: An array of reducers. + /// - Returns: A single reducer. + @available( + iOS, + deprecated: 9999.0, + message: + """ + This API has been soft-deprecated in favor of combining reducers in a 'ReducerBuilder'. Read the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public static func combine(_ reducers: [Self]) -> Self { + Self { state, action, environment in + .merge(reducers.map { $0.reducer(&state, action, environment) }) + } + } + + /// This API has been soft-deprecated in favor of ``Scope``. Read + /// for more information. + /// + /// Transforms a reducer that works on child state, action, and environment into one that works on + /// parent state, action and environment. It accomplishes this by providing 3 transformations to + /// the method: + /// + /// * A writable key path that can get/set a piece of child state from the parent state. + /// * A case path that can extract/embed a child action into a parent action. + /// * A function that can transform the parent environment into a child environment. + /// + /// This operation is important for breaking down large reducers into small ones. When used with + /// the ``combine(_:)-y8ee`` operator you can define many reducers that work on small pieces of + /// domain, and then _pull them back_ and _combine_ them into one big reducer that works on a + /// large domain. + /// + /// ```swift + /// // Global domain that holds a child domain: + /// struct AppState { var settings: SettingsState, /* rest of state */ } + /// enum AppAction { case settings(SettingsAction), /* other actions */ } + /// struct AppEnvironment { var settings: SettingsEnvironment, /* rest of dependencies */ } + /// + /// // A reducer that works on the child domain: + /// let settingsReducer = AnyReducer { ... } + /// + /// // Pullback the settings reducer so that it works on all of the app domain: + /// let appReducer = AnyReducer.combine( + /// settingsReducer.pullback( + /// state: \.settings, + /// action: /AppAction.settings, + /// environment: { $0.settings } + /// ), + /// + /// /* other reducers */ + /// ) + /// ``` + /// + /// - Parameters: + /// - toChildState: A key path that can get/set `State` inside `ParentState`. + /// - toChildAction: A case path that can extract/embed `Action` from `ParentAction`. + /// - toChildEnvironment: A function that transforms `ParentEnvironment` into `Environment`. + /// - Returns: A reducer that works on `ParentState`, `ParentAction`, `ParentEnvironment`. + @available( + iOS, + deprecated: 9999.0, + message: + """ + This API has been soft-deprecated in favor of 'Scope'. Read the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public func pullback( + state toLocalState: WritableKeyPath, + action toLocalAction: CasePath, + environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment + ) -> AnyReducer { + .init { globalState, globalAction, globalEnvironment in + guard let localAction = toLocalAction.extract(from: globalAction) else { return .none } + return self.reducer( + &globalState[keyPath: toLocalState], + localAction, + toLocalEnvironment(globalEnvironment) + ) + .map(toLocalAction.embed) + } + } + + public func pullback( + state toLocalState: StatePath, + action toLocalAction: ActionPath, + environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment + ) -> AnyReducer + where + StatePath: WritablePath, StatePath.Root == GlobalState, StatePath.Value == State, + ActionPath: WritablePath, ActionPath.Root == GlobalAction, ActionPath.Value == Action + { + return .init { globalState, globalAction, globalEnvironment in + guard + var localState = toLocalState.extract(from: globalState), + let localAction = toLocalAction.extract(from: globalAction) + else { return .none } + let effect = + self + .reducer(&localState, localAction, toLocalEnvironment(globalEnvironment)) + .map { localAction -> GlobalAction in + var globalAction = globalAction + toLocalAction.set(into: &globalAction, localAction) + return globalAction + } + toLocalState.set(into: &globalState, localState) + return effect + } + } + + /// This API has been soft-deprecated in favor of + /// ``ReducerProtocol/ifLet(_:action:then:file:fileID:line:)``. Read + /// for more information. + /// + /// Transforms a reducer that works on non-optional state into one that works on optional state by + /// only running the non-optional reducer when state is non-nil. + /// + /// Often used in tandem with ``pullback(state:action:environment:)`` to transform a reducer on a + /// non-optional child domain into a reducer that can be combined with a reducer on a parent + /// domain that contains some optional child domain: + /// + /// ```swift + /// // Parent domain that holds an optional child domain: + /// struct AppState { var modal: ModalState? } + /// enum AppAction { case modal(ModalAction) } + /// struct AppEnvironment { var mainQueue: AnySchedulerOf } + /// + /// // A reducer that works on the non-optional child domain: + /// let modalReducer = Reducer.combine( + /// modalReducer.optional().pullback( + /// state: \.modal, + /// action: /AppAction.modal, + /// environment: { ModalEnvironment(mainQueue: $0.mainQueue) } + /// ), + /// Reducer { state, action, environment in + /// ... + /// } + /// ) + /// ``` + /// + /// Take care when combining optional reducers into parent domains. An optional reducer cannot + /// process actions in its domain when its state is `nil`. If a child action is sent to an + /// optional reducer when child state is `nil`, it is generally considered a logic error. There + /// are a few ways in which these errors can sneak into a code base: + /// + /// * A parent reducer sets child state to `nil` when processing a child action and runs + /// _before_ the child reducer: + /// + /// ```swift + /// let parentReducer = Reducer.combine( + /// // When combining reducers, the parent reducer runs first + /// Reducer { state, action, environment in + /// switch action { + /// case .child(.didDisappear): + /// // And `nil`s out child state when processing a child action + /// state.child = nil + /// return .none + /// ... + /// } + /// }, + /// // Before the child reducer runs + /// childReducer.optional().pullback(...) + /// ) + /// + /// let childReducer = Reducer< + /// ChildState, ChildAction, ChildEnvironment + /// > { state, action environment in + /// case .didDisappear: + /// // This action is never received here because child state is `nil` in the parent + /// ... + /// } + /// ``` + /// + /// To ensure that a child reducer can process any action that a parent may use to `nil` out + /// its state, combine it _before_ the parent: + /// + /// ```swift + /// let parentReducer = Reducer.combine( + /// // The child runs first + /// childReducer.optional().pullback(...), + /// // The parent runs after + /// Reducer { state, action, environment in + /// ... + /// } + /// ) + /// ``` + /// + /// * A child effect feeds a child action back into the store when child state is `nil`: + /// + /// ```swift + /// let childReducer = Reducer< + /// ChildState, ChildAction, ChildEnvironment + /// > { state, action environment in + /// switch action { + /// case .onAppear: + /// // An effect may want to feed its result back to the child domain in an action + /// return environment.apiClient + /// .request() + /// .map(ChildAction.response) + /// + /// case let .response(response): + /// // But the child cannot process this action if its state is `nil` in the parent + /// ... + /// } + /// } + /// ``` + /// + /// It is perfectly reasonable to ignore the result of an effect when child state is `nil`, + /// for example one-off effects that you don't want to cancel. However, many long-living + /// effects _should_ be explicitly canceled when tearing down a child domain: + /// + /// ```swift + /// let childReducer = Reducer< + /// ChildState, ChildAction, ChildEnvironment + /// > { state, action environment in + /// enum MotionID {} + /// + /// switch action { + /// case .onAppear: + /// // Mark long-living effects that shouldn't outlive their domain cancellable + /// return environment.motionClient + /// .start() + /// .map(ChildAction.motion) + /// .cancellable(id: MotionID.self) + /// + /// case .onDisappear: + /// // And explicitly cancel them when the domain is torn down + /// return .cancel(id: MotionID.self) + /// ... + /// } + /// } + /// ``` + /// + /// * A view store sends a child action when child state is `nil`: + /// + /// ```swift + /// WithViewStore(self.parentStore) { parentViewStore in + /// // If child state is `nil`, it cannot process this action. + /// Button("Child Action") { parentViewStore.send(.child(.action)) } + /// ... + /// } + /// ``` + /// + /// Use ``Store/scope(state:action:)`` with ``IfLetStore`` or ``Store/ifLet(then:else:)`` to + /// ensure that views can only send child actions when the child domain is non-`nil`. + /// + /// ```swift + /// IfLetStore( + /// self.parentStore.scope(state: { $0.child }, action: { .child($0) } + /// ) { childStore in + /// // This destination only appears when child state is non-`nil` + /// WithViewStore(childStore) { childViewStore in + /// // So this action can only be sent when child state is non-`nil` + /// Button("Child Action") { childViewStore.send(.action) } + /// } + /// ... + /// } + /// ``` + /// + /// - See also: ``IfLetStore``, a SwiftUI helper for transforming a store on optional state into a + /// store on non-optional state. + /// - See also: ``Store/ifLet(then:else:)``, a UIKit helper for doing imperative work with a store + /// on optional state. + /// + /// - Returns: A reducer that works on optional state. + @available( + iOS, + deprecated: 9999.0, + message: + """ + This API has been soft-deprecated in favor of 'ReducerProtocol.ifLet'. Read the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public func optional( + breakpointOnNil: Bool = true, + _ file: StaticString = #file, + _ line: UInt = #line + ) -> AnyReducer< + State?, Action, Environment + > { + .init { state, action, environment in + guard state != nil else { + #if DEBUG + if breakpointOnNil { + fputs( + """ + --- + Warning: AnyReducer.optional@\(file):\(line) + + "\(debugCaseOutput(action))" was received by an optional reducer when its state was \ + "nil". This is generally considered an application logic error, and can happen for a \ + few reasons: + + * The optional reducer was combined with or run from another reducer that set \ + "\(State.self)" to "nil" before the optional reducer ran. Combine or run optional \ + reducers before reducers that can set their state to "nil". This ensures that \ + optional reducers can handle their actions while their state is still non-"nil". + + * An in-flight effect emitted this action while state was "nil". While it may be \ + perfectly reasonable to ignore this action, you may want to cancel the associated \ + effect before state is set to "nil", especially if it is a long-living effect. + + * This action was sent to the store while state was "nil". Make sure that actions \ + for this reducer can only be sent to a view store when state is non-"nil". In \ + SwiftUI applications, use "IfLetStore". + --- + + """, + stderr + ) + raise(SIGTRAP) + } + #endif + return .none + } + return self.reducer(&state!, action, environment) + } + } + + /// This API has been soft-deprecated in favor of + /// ``ReducerProtocol/forEach(_:action:_:file:fileID:line:)``. Read + /// for more information. + /// + /// A version of ``pullback(state:action:environment:)`` that transforms a reducer that works on + /// an element into one that works on an identified array of elements. + /// + /// ```swift + /// // Parent domain that holds a collection of child domains: + /// struct AppState { var todos: IdentifiedArrayOf } + /// enum AppAction { case todo(id: Todo.ID, action: TodoAction) } + /// struct AppEnvironment { var mainQueue: AnySchedulerOf } + /// + /// // A reducer that works on an element's domain: + /// let todoReducer = Reducer { ... } + /// + /// // Pullback the todo reducer so that it works on all of the app domain: + /// let appReducer = Reducer.combine( + /// todoReducer.forEach( + /// state: \.todos, + /// action: /AppAction.todo(id:action:), + /// environment: { _ in TodoEnvironment() } + /// ), + /// Reducer { state, action, environment in + /// ... + /// } + /// ) + /// ``` + /// + /// 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: + /// - toElementsState: A key path that can get/set a collection of `State` elements inside + /// `ParentState`. + /// - toElementAction: A case path that can extract/embed `(ID, Action)` from `ParentAction`. + /// - toElementEnvironment: A function that transforms `ParentEnvironment` into `Environment`. + /// - Returns: A reducer that works on `ParentState`, `ParentAction`, `ParentEnvironment`. + @available( + iOS, + deprecated: 9999.0, + message: + """ + This API has been soft-deprecated in favor of 'ReducerProtocol.forEach'. Read the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public func forEach( + state toLocalState: WritableKeyPath, + action toLocalAction: CasePath, + environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, + breakpointOnNil: Bool = true, + _ file: StaticString = #file, + _ line: UInt = #line + ) -> AnyReducer { + .init { globalState, globalAction, globalEnvironment in + guard let (key, localAction) = toLocalAction.extract(from: globalAction) else { + return .none + } + if globalState[keyPath: toLocalState][key] == nil { + #if DEBUG + if breakpointOnNil { + fputs( + """ + --- + Warning: AnyReducer.forEach@\(file):\(line) + + "\(debugCaseOutput(localAction))" was received by a "forEach" reducer at key \(key) \ + when its state contained no element at this key. This is generally considered an \ + application logic error, and can happen for a few reasons: + + * This "forEach" reducer was combined with or run from another reducer that removed \ + the element at this key when it handled this action. To fix this make sure that this \ + "forEach" reducer is run before any other reducers that can move or remove elements \ + from state. This ensures that "forEach" reducers can handle their actions for the \ + element at the intended key. + + * An in-flight effect emitted this action while state contained no element at this \ + key. It may be perfectly reasonable to ignore this action, but you also may want to \ + cancel the effect it originated from when removing a value from the dictionary, \ + especially if it is a long-living effect. + + * This action was sent to the store while its state contained no element at this \ + key. To fix this make sure that actions for this reducer can only be sent to a view \ + store when its state contains an element at this key. + --- + + """, + stderr + ) + raise(SIGTRAP) + } + #endif + return .none + } + return self.reducer( + &globalState[keyPath: toLocalState][key]!, + localAction, + toLocalEnvironment(globalEnvironment) + ) + .map { toLocalAction.embed((key, $0)) } + } + } + + /// A version of `pullback` that transforms a reducer that works on an element into one that works + /// on an identified array of elements. + /// + /// // 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: AnySchedulerOf } + /// + /// // A reducer that works on a local domain: + /// let todoReducer = Reducer { ... } + /// + /// // Pullback the local todo reducer so that it works on all of the app domain: + /// let appReducer = Reducer.combine( + /// todoReducer.forEach( + /// state: \.todos, + /// action: /AppAction.todo(id:action:), + /// environment: { _ in TodoEnvironment() } + /// ), + /// Reducer { state, action, environment in + /// ... + /// } + /// ) + /// + /// 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 + /// `GlobalState`. + /// - toLocalAction: A case path that can extract/embed `(Collection.Index, Action)` from + /// `GlobalAction`. + /// - toLocalEnvironment: A function that transforms `GlobalEnvironment` into `Environment`. + /// - breakpointOnNil: Raises `SIGTRAP` signal when an action is sent to the reducer but the + /// identified array does not contain an element with the action's identifier. This is + /// generally considered a logic error, as a child reducer cannot process a child action + /// for unavailable child state. + /// - Returns: A reducer that works on `GlobalState`, `GlobalAction`, `GlobalEnvironment`. + public func forEach( + state toLocalState: WritableKeyPath>, + action toLocalAction: CasePath, + environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment, + breakpointOnNil: Bool = true, + _ file: StaticString = #file, + _ line: UInt = #line + ) -> AnyReducer { + .init { globalState, globalAction, globalEnvironment in + guard let (id, localAction) = toLocalAction.extract(from: globalAction) else { + return .none + } + if globalState[keyPath: toLocalState][id: id] == nil { + #if DEBUG + if breakpointOnNil { + fputs( + """ + --- + Warning: AnyReducer.forEach@\(file):\(line) + + "\(debugCaseOutput(localAction))" was received by a "forEach" reducer at id \(id) \ + when its state contained no element at this id. This is generally considered an \ + application logic error, and can happen for a few reasons: + + * This "forEach" reducer was combined with or run from another reducer that removed \ + the element at this id when it handled this action. To fix this make sure that this \ + "forEach" reducer is run before any other reducers that can move or remove elements \ + from state. This ensures that "forEach" reducers can handle their actions for the \ + element at the intended id. + + * An in-flight effect emitted this action while state contained no element at this \ + id. It may be perfectly reasonable to ignore this action, but you also may want to \ + cancel the effect it originated from when removing an element from the identified \ + array, especially if it is a long-living effect. + + * This action was sent to the store while its state contained no element at this id. \ + To fix this make sure that actions for this reducer can only be sent to a view store \ + when its state contains an element at this id. In SwiftUI applications, use \ + "ForEachStore". + --- + + """, + stderr + ) + raise(SIGTRAP) + } + #endif + return .none + } + return self.reducer( + &globalState[keyPath: toLocalState][id: id]!, + localAction, + toLocalEnvironment(globalEnvironment) + ) + .map { toLocalAction.embed((id, $0)) } + } + } + + public func combined(with other: Self) -> Self { + .combine(self, other) + } + + /// This API has been soft-deprecated in favor of ``ReducerProtocol/reduce(into:action:)-4nzr2``. + /// Read for more information. + /// + /// Runs the reducer. + /// + /// - Parameters: + /// - state: Mutable state. + /// - action: An action. + /// - environment: An environment. + /// - Returns: An effect that can emit zero or more actions. + @available( + iOS, + deprecated: 9999.0, + message: + """ + This API has been soft-deprecated in favor of 'ReducerProtocol.reduce(into:action:)'. Read the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public func run( + _ state: inout State, + _ action: Action, + _ environment: Environment + ) -> Effect { + #if DEBUG + reducer( + &state, action, Bootstrap.get(environment: type(of: environment)) ?? environment) + #else + reducer(&state, action, environment) + #endif + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift new file mode 100644 index 0000000..d638ae0 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerCompatibility.swift @@ -0,0 +1,44 @@ +extension Reduce { + public init( + _ reducer: AnyReducer, + environment: Environment + ) { + self.init(internal: { state, action in + reducer.run(&state, action, environment) + }) + } +} + +extension AnyReducer { + public init(@ReducerBuilderOf _ build: @escaping (Environment) -> R) + where R.State == State, R.Action == Action { + self.init { state, action, environment in + build(environment).reduce(into: &state, action: action) + } + } + + public init(_ reducer: R) where R.State == State, R.Action == Action { + self.init { _ in reducer } + } +} + +extension Store { + /// Initializes a store from an initial state, a reducer, and an environment. + /// + /// - Parameters: + /// - initialState: The state to start the application in. + /// - reducer: The reducer that powers the business logic of the application. + /// - environment: The environment of dependencies for the application. + public convenience init( + initialState: State, + reducer: AnyReducer, + environment: Environment, + useNewScope: Bool = StoreConfig.default.useNewScope() + ) { + self.init( + initialState: initialState, + reducer: Reduce(reducer, environment: environment), + useNewScope: useNewScope + ) + } +} diff --git a/Sources/RxComposableArchitecture/Debugging/ReducerDebugging.swift b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerDebug.swift similarity index 52% rename from Sources/RxComposableArchitecture/Debugging/ReducerDebugging.swift rename to Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerDebug.swift index 15587ed..865442a 100644 --- a/Sources/RxComposableArchitecture/Debugging/ReducerDebugging.swift +++ b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerDebug.swift @@ -1,99 +1,8 @@ -// -// ReducerDebugging.swift -// RxComposableArchitecture -// -// Created by Wendy Liga on 19/05/20. -// - import CasePaths import Dispatch -/// Determines how the string description of an action should be printed when using the `.debug()` -/// higher-order reducer. -public enum ActionFormat { - /// Prints the action in a single line by only specifying the labels of the associated values: - /// - /// Action.screenA(.row(index:, action: .textChanged(query:))) - case labelsOnly - /// Prints the action in a multiline, pretty-printed format, including all the labels of - /// any associated values, as well as the data held in the associated values: - /// - /// Action.screenA( - /// ScreenA.row( - /// index: 1, - /// action: RowAction.textChanged( - /// query: "Hi" - /// ) - /// ) - /// ) - case prettyPrint -} - -/// A container for storing action filters. -/// -/// The logic behind having this rather than a normal closure is that it allows us to namespace and gather action filters together in a consistent manner. -/// - Note: You should be adding extensions in your modules and exposing common filters you might want to use to focus your debugging work, e.g. -/// ```swift -/// extension ActionFilter where Action == AppAction { -/// static var windowActions: Self { -/// Self(isIncluded: { -/// switch $0 { -/// case .windows: -/// return true -/// default: -/// return false -/// } -/// }) -/// } -/// } -/// ``` -public struct ActionFilter { - private let isIncluded: (Action) -> Bool - - public init(isIncluded: @escaping (Action) -> Bool) { - self.isIncluded = isIncluded - } - - public func callAsFunction(_ action: Action) -> Bool { - isIncluded(action) - } - - /// Include all actions - public static var all: Self { - .init(isIncluded: { _ in true }) - } - - /// negates the filter - public static func not(_ filter: Self) -> Self { - .init(isIncluded: { !filter($0) }) - } - - /// Allows all actions except those specified - public static func allExcept(_ actions: Self...) -> Self { - allExcept(actions) - } - - /// Allows all actions except those specified - public static func allExcept(_ actions: [Self]) -> Self { - .init(isIncluded: { action in - !actions.contains(where: { $0(action) }) - }) - } - - /// Allows any of the specified actions - public static func anyOf(_ actions: Self...) -> Self { - .anyOf(actions) - } - - /// Allows any of the specified actions - public static func anyOf(_ actions: [Self]) -> Self { - .init(isIncluded: { action in - actions.contains(where: { $0(action) }) - }) - } -} - -extension Reducer { +@available(iOS, deprecated: 9999.0, message: "Use a 'ReducerProtocol' conformance, instead.") +extension AnyReducer { /// Prints debug messages describing all received actions and state mutations. /// /// Printing is only done in debug (`#if DEBUG`) builds. @@ -102,7 +11,7 @@ extension Reducer { /// - prefix: A string with which to prefix all debug messages. /// - toDebugEnvironment: A function that transforms an environment into a debug environment by /// describing a print function and a queue to print from. Defaults to a function that ignores - /// the environment and returns a default `DebugEnvironment` that uses Swift's `print` + /// the environment and returns a default ``DebugEnvironment`` that uses Swift's `print` /// function and a background queue. /// - Returns: A reducer that prints debug messages for all received actions. public func debug( @@ -111,8 +20,8 @@ extension Reducer { environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in DebugEnvironment() } - ) -> Reducer { - debug( + ) -> Self { + self.debug( prefix, state: { $0 }, action: .self, @@ -129,7 +38,7 @@ extension Reducer { /// - prefix: A string with which to prefix all debug messages. /// - toDebugEnvironment: A function that transforms an environment into a debug environment by /// describing a print function and a queue to print from. Defaults to a function that ignores - /// the environment and returns a default `DebugEnvironment` that uses Swift's `print` + /// the environment and returns a default ``DebugEnvironment`` that uses Swift's `print` /// function and a background queue. /// - Returns: A reducer that prints debug messages for all received actions. public func debugActions( @@ -138,8 +47,8 @@ extension Reducer { environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in DebugEnvironment() } - ) -> Reducer { - debug( + ) -> Self { + self.debug( prefix, state: { _ in () }, action: .self, @@ -148,71 +57,49 @@ extension Reducer { ) } - public func debug( - _ prefix: String = "", - actionFormat: ActionFormat = .prettyPrint, - allowedActions: ActionFilter = .all, - environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in - DebugEnvironment() - } - ) -> Reducer { - debug( - prefix, - state: { _ in () }, - action: .self, - actionFormat: actionFormat, - allowedActions: allowedActions, - environment: toDebugEnvironment - ) - } - - /// Prints debug messages describing all received local actions and local state mutations. + /// Prints debug messages describing all received actions and state mutations. /// /// Printing is only done in debug (`#if DEBUG`) builds. /// /// - Parameters: /// - prefix: A string with which to prefix all debug messages. - /// - toLocalState: A function that filters state to be printed. - /// - toLocalAction: A case path that filters actions that are printed. + /// - toDebugState: A function that filters state to be printed. + /// - toDebugAction: A case path that filters actions that are printed. /// - toDebugEnvironment: A function that transforms an environment into a debug environment by /// describing a print function and a queue to print from. Defaults to a function that ignores - /// the environment and returns a default `DebugEnvironment` that uses Swift's `print` + /// the environment and returns a default ``DebugEnvironment`` that uses Swift's `print` /// function and a background queue. /// - Returns: A reducer that prints debug messages for all received actions. - public func debug( + public func debug( _ prefix: String = "", - state toLocalState: @escaping (State) -> LocalState, - action toLocalAction: CasePath, + state toDebugState: @escaping (State) -> DebugState, + action toDebugAction: CasePath, actionFormat: ActionFormat = .prettyPrint, - allowedActions: ActionFilter = .all, environment toDebugEnvironment: @escaping (Environment) -> DebugEnvironment = { _ in DebugEnvironment() } - ) -> Reducer { + ) -> Self { #if DEBUG return .init { state, action, environment in - let previousState = toLocalState(state) + let previousState = toDebugState(state) let effects = self.run(&state, action, environment) - guard let localAction = toLocalAction.extract(from: action) else { return effects } - let nextState = toLocalState(state) + guard let debugAction = toDebugAction.extract(from: action) else { return effects } + let nextState = toDebugState(state) let debugEnvironment = toDebugEnvironment(environment) - guard allowedActions(action) else { - return effects - } - return .merge( .fireAndForget { debugEnvironment.queue.async { var actionOutput = "" if actionFormat == .prettyPrint { - customDump(localAction, to: &actionOutput, indent: 2) + customDump(debugAction, to: &actionOutput, indent: 2) } else { - actionOutput.write(debugCaseOutput(localAction).indent(by: 2)) + actionOutput.write(debugCaseOutput(debugAction).indent(by: 2)) } let stateOutput = - LocalState.self == Void.self - ? "" - : diff(previousState, nextState).map { "\($0)\n" } ?? " (No state changes)\n" + DebugState.self == Void.self + ? "" + : diff(previousState, nextState).map { "\($0)\n" } + ?? " (No state changes)\n" debugEnvironment.printer( """ \(prefix.isEmpty ? "" : "\(prefix): ")received action: @@ -231,6 +118,51 @@ extension Reducer { } } +public enum ActionFormat: Sendable { + /// Prints the action in a single line by only specifying the labels of the associated values: + /// + /// ```swift + /// Action.screenA(.row(index:, action: .textChanged(query:))) + /// ``` + case labelsOnly + + /// Prints the action in a multiline, pretty-printed format, including all the labels of + /// any associated values, as well as the data held in the associated values: + /// + /// ```swift + /// Action.screenA( + /// ScreenA.row( + /// index: 1, + /// action: RowAction.textChanged( + /// query: "Hi" + /// ) + /// ) + /// ) + /// ``` + case prettyPrint +} + +/// An environment for debug-printing reducers. +@available( + iOS, + deprecated: 9999.0, + message: "Call 'debug()' on a 'ReducerProtocol' conformance, instead." +) +@available( + macOS, + deprecated: 9999.0, + message: "Call 'debug()' on a 'ReducerProtocol' conformance, instead." +) +@available( + tvOS, + deprecated: 9999.0, + message: "Call 'debug()' on a 'ReducerProtocol' conformance, instead." +) +@available( + watchOS, + deprecated: 9999.0, + message: "Call 'debug()' on a 'ReducerProtocol' conformance, instead." +) /// An environment for debug-printing reducers. public struct DebugEnvironment { public var printer: (String) -> Void @@ -253,5 +185,5 @@ public struct DebugEnvironment { private let _queue = DispatchQueue( label: "com.tokopedia.Tokopedia.DebugEnvironment", - qos: .background + qos: .default ) diff --git a/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerSignpost.swift b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerSignpost.swift new file mode 100644 index 0000000..c127f53 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/AnyReducer/AnyReducerSignpost.swift @@ -0,0 +1,159 @@ +import RxSwift +import os.signpost + +@available(iOS, deprecated: 9999.0, message: "Use a 'ReducerProtocol' conformance, instead.") +@available(macOS, deprecated: 9999.0, message: "Use a 'ReducerProtocol' conformance, instead.") +@available(tvOS, deprecated: 9999.0, message: "Use a 'ReducerProtocol' conformance, instead.") +@available(watchOS, deprecated: 9999.0, message: "Use a 'ReducerProtocol' conformance, instead.") +extension AnyReducer { + /// Instruments the reducer with + /// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data). + /// Each invocation of the reducer will be measured by an interval, and the lifecycle of its + /// effects will be measured with interval and event signposts. + /// + /// To use, build your app for Instruments (⌘I), create a blank instrument, and then use the "+" + /// icon at top right to add the signpost instrument. Start recording your app (red button at top + /// left) and then you should see timing information for every action sent to the store and every + /// effect executed. + /// + /// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living + /// effects. For example, if you start an effect (e.g. a location manager) in `onAppear` and + /// forget to tear down the effect in `onDisappear`, it will clearly show in Instruments that the + /// effect never completed. + /// + /// - Parameters: + /// - prefix: A string to print at the beginning of the formatted message for the signpost. + /// - log: An `OSLog` to use for signposts. + /// - Returns: A reducer that has been enhanced with instrumentation. + public func signpost( + _ prefix: String = "", + log: OSLog = OSLog( + subsystem: "co.pointfree.composable-architecture", + category: "Reducer Instrumentation" + ) + ) -> Self { + guard log.signpostsEnabled else { return self } + + // NB: Prevent rendering as "N/A" in Instruments + let zeroWidthSpace = "\u{200B}" + + let prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] " + + return Self { state, action, environment in + var actionOutput: String! + if log.signpostsEnabled { + actionOutput = debugCaseOutput(action) + os_signpost(.begin, log: log, name: "Action", "%s%s", prefix, actionOutput) + } + let effects = self.run(&state, action, environment) + if log.signpostsEnabled { + os_signpost(.end, log: log, name: "Action") + return + effects + .effectSignpost(prefix, log: log, actionOutput: actionOutput) + .eraseToEffect() + } + return effects + } + } +} + +extension ObservableType { + @available(iOS 12.0, *) + @usableFromInline + internal func effectSignpost(_ prefix: String, log: OSLog, actionOutput: String) -> Observable< + Element + > { + let sid = OSSignpostID(log: log) + + return `do`( + onNext: { _ in + os_signpost( + .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, + actionOutput) + }, + onCompleted: { + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) + }, + onSubscribe: { + os_signpost( + .begin, log: log, name: "Effect", signpostID: sid, "%sStarting from %s", prefix, + actionOutput) + }, + onSubscribed: { + os_signpost( + .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, + actionOutput) + }, + onDispose: { + os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sDisposed", prefix) + } + ) + } +} + +//extension Publisher where Failure == Never { +// @usableFromInline +// func effectSignpost( +// _ prefix: String, +// log: OSLog, +// actionOutput: String +// ) -> Publishers.HandleEvents { +// let sid = OSSignpostID(log: log) +// +// return +// self +// .handleEvents( +// receiveSubscription: { _ in +// os_signpost( +// .begin, log: log, name: "Effect", signpostID: sid, "%sStarted from %s", prefix, +// actionOutput) +// }, +// receiveOutput: { value in +// os_signpost( +// .event, log: log, name: "Effect Output", "%sOutput from %s", prefix, actionOutput) +// }, +// receiveCompletion: { completion in +// switch completion { +// case .finished: +// os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sFinished", prefix) +// } +// }, +// receiveCancel: { +// os_signpost(.end, log: log, name: "Effect", signpostID: sid, "%sCancelled", prefix) +// } +// ) +// } +//} + +@usableFromInline +func debugCaseOutput(_ value: Any) -> String { + func debugCaseOutputHelp(_ value: Any) -> String { + let mirror = Mirror(reflecting: value) + switch mirror.displayStyle { + case .enum: + guard let child = mirror.children.first else { + let childOutput = "\(value)" + return childOutput == "\(type(of: value))" ? "" : ".\(childOutput)" + } + let childOutput = debugCaseOutputHelp(child.value) + return ".\(child.label ?? "")\(childOutput.isEmpty ? "" : "(\(childOutput))")" + case .tuple: + return mirror.children.map { label, value in + let childOutput = debugCaseOutputHelp(value) + return + "\(label.map { isUnlabeledArgument($0) ? "_:" : "\($0):" } ?? "")\(childOutput.isEmpty ? "" : " \(childOutput)")" + } + .joined(separator: ", ") + default: + return "" + } + } + + return (value as? CustomDebugStringConvertible)?.debugDescription + ?? "\(typeName(type(of: value)))\(debugCaseOutputHelp(value))" +} + +private func isUnlabeledArgument(_ label: String) -> Bool { + label.firstIndex(where: { $0 != "." && !$0.isNumber }) == nil +} diff --git a/Sources/RxComposableArchitecture/Reducer/ReducerBuilder.swift b/Sources/RxComposableArchitecture/Reducer/ReducerBuilder.swift new file mode 100644 index 0000000..ead286d --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/ReducerBuilder.swift @@ -0,0 +1,466 @@ +/// A result builder for combining reducers into a single reducer by running each, one after the +/// other, and returning their merged effects. +/// +/// It is most common to encounter a reducer builder context when conforming a type to +/// ``ReducerProtocol`` and implementing its ``ReducerProtocol/body-swift.property-97ymy`` property. +/// +/// See ``CombineReducers`` for an entry point into a reducer builder context. +@resultBuilder +public enum ReducerBuilder { +#if swift(>=5.7) + @inlinable + public static func buildArray( + _ reducers: [some ReducerProtocol] + ) -> some ReducerProtocol { + _SequenceMany(reducers: reducers) + } + + @inlinable + public static func buildBlock() -> some ReducerProtocol { + EmptyReducer() + } + + @inlinable + public static func buildBlock( + _ reducer: some ReducerProtocol + ) -> some ReducerProtocol { + reducer + } + + @inlinable + public static func buildEither( + first reducer: R0 + ) -> _Conditional + where R0.State == State, R0.Action == Action, R1.State == State, R1.Action == Action { + .first(reducer) + } + + @inlinable + public static func buildEither( + second reducer: R1 + ) -> _Conditional + where R0.State == State, R0.Action == Action, R1.State == State, R1.Action == Action { + .second(reducer) + } + + @inlinable + public static func buildExpression( + _ expression: some ReducerProtocol + ) -> some ReducerProtocol { + expression + } + + @inlinable + public static func buildFinalResult( + _ reducer: some ReducerProtocol + ) -> some ReducerProtocol { + reducer + } + + @inlinable + public static func buildLimitedAvailability( + _ wrapped: some ReducerProtocol + ) -> some ReducerProtocol { + _Optional(wrapped: wrapped) + } + + @inlinable + public static func buildOptional( + _ wrapped: (some ReducerProtocol)? + ) -> some ReducerProtocol { + _Optional(wrapped: wrapped) + } + + @inlinable + public static func buildPartialBlock( + first: some ReducerProtocol + ) -> some ReducerProtocol { + first + } + + @inlinable + public static func buildPartialBlock( + accumulated: some ReducerProtocol, next: some ReducerProtocol + ) -> some ReducerProtocol { + _Sequence(accumulated, next) + } +#else + @inlinable + public static func buildArray(_ reducers: [R]) -> _SequenceMany + where R.State == State, R.Action == Action { + _SequenceMany(reducers: reducers) + } + + @inlinable + public static func buildBlock() -> EmptyReducer { + EmptyReducer() + } + + @inlinable + public static func buildBlock(_ reducer: R) -> R + where R.State == State, R.Action == Action { + reducer + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol + >( + _ r0: R0, + _ r1: R1 + ) -> _Sequence + where R0.State == State, R0.Action == Action { + _Sequence(r0, r1) + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol, + R2: ReducerProtocol + >( + _ r0: R0, + _ r1: R1, + _ r2: R2 + ) -> _Sequence<_Sequence, R2> + where R0.State == State, R0.Action == Action { + _Sequence(_Sequence(r0, r1), r2) + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol, + R2: ReducerProtocol, + R3: ReducerProtocol + >( + _ r0: R0, + _ r1: R1, + _ r2: R2, + _ r3: R3 + ) -> _Sequence<_Sequence<_Sequence, R2>, R3> + where R0.State == State, R0.Action == Action { + _Sequence(_Sequence(_Sequence(r0, r1), r2), r3) + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol, + R2: ReducerProtocol, + R3: ReducerProtocol, + R4: ReducerProtocol + >( + _ r0: R0, + _ r1: R1, + _ r2: R2, + _ r3: R3, + _ r4: R4 + ) -> _Sequence<_Sequence<_Sequence<_Sequence, R2>, R3>, R4> + where R0.State == State, R0.Action == Action { + _Sequence(_Sequence(_Sequence(_Sequence(r0, r1), r2), r3), r4) + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol, + R2: ReducerProtocol, + R3: ReducerProtocol, + R4: ReducerProtocol, + R5: ReducerProtocol + >( + _ r0: R0, + _ r1: R1, + _ r2: R2, + _ r3: R3, + _ r4: R4, + _ r5: R5 + ) -> _Sequence<_Sequence<_Sequence<_Sequence<_Sequence, R2>, R3>, R4>, R5> + where R0.State == State, R0.Action == Action { + _Sequence(_Sequence(_Sequence(_Sequence(_Sequence(r0, r1), r2), r3), r4), r5) + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol, + R2: ReducerProtocol, + R3: ReducerProtocol, + R4: ReducerProtocol, + R5: ReducerProtocol, + R6: ReducerProtocol + >( + _ r0: R0, + _ r1: R1, + _ r2: R2, + _ r3: R3, + _ r4: R4, + _ r5: R5, + _ r6: R6 + ) -> _Sequence< + _Sequence<_Sequence<_Sequence<_Sequence<_Sequence, R2>, R3>, R4>, R5>, R6 + > + where R0.State == State, R0.Action == Action { + _Sequence(_Sequence(_Sequence(_Sequence(_Sequence(_Sequence(r0, r1), r2), r3), r4), r5), r6) + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol, + R2: ReducerProtocol, + R3: ReducerProtocol, + R4: ReducerProtocol, + R5: ReducerProtocol, + R6: ReducerProtocol, + R7: ReducerProtocol + >( + _ r0: R0, + _ r1: R1, + _ r2: R2, + _ r3: R3, + _ r4: R4, + _ r5: R5, + _ r6: R6, + _ r7: R7 + ) -> _Sequence< + _Sequence<_Sequence<_Sequence<_Sequence<_Sequence<_Sequence, R2>, R3>, R4>, R5>, R6>, + R7 + > + where R0.State == State, R0.Action == Action { + _Sequence( + _Sequence( + _Sequence(_Sequence(_Sequence(_Sequence(_Sequence(r0, r1), r2), r3), r4), r5), r6 + ), + r7 + ) + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol, + R2: ReducerProtocol, + R3: ReducerProtocol, + R4: ReducerProtocol, + R5: ReducerProtocol, + R6: ReducerProtocol, + R7: ReducerProtocol, + R8: ReducerProtocol + >( + _ r0: R0, + _ r1: R1, + _ r2: R2, + _ r3: R3, + _ r4: R4, + _ r5: R5, + _ r6: R6, + _ r7: R7, + _ r8: R8 + ) -> _Sequence< + _Sequence< + _Sequence< + _Sequence<_Sequence<_Sequence<_Sequence<_Sequence, R2>, R3>, R4>, R5>, R6 + >, + R7 + >, + R8 + > + where R0.State == State, R0.Action == Action { + _Sequence( + _Sequence( + _Sequence( + _Sequence(_Sequence(_Sequence(_Sequence(_Sequence(r0, r1), r2), r3), r4), r5), r6 + ), + r7 + ), + r8 + ) + } + + @inlinable + public static func buildBlock< + R0: ReducerProtocol, + R1: ReducerProtocol, + R2: ReducerProtocol, + R3: ReducerProtocol, + R4: ReducerProtocol, + R5: ReducerProtocol, + R6: ReducerProtocol, + R7: ReducerProtocol, + R8: ReducerProtocol, + R9: ReducerProtocol + >( + _ r0: R0, + _ r1: R1, + _ r2: R2, + _ r3: R3, + _ r4: R4, + _ r5: R5, + _ r6: R6, + _ r7: R7, + _ r8: R8, + _ r9: R9 + ) -> _Sequence< + _Sequence< + _Sequence< + _Sequence< + _Sequence<_Sequence<_Sequence<_Sequence<_Sequence, R2>, R3>, R4>, R5>, R6 + >, + R7 + >, + R8 + >, + R9 + > + where R0.State == State, R0.Action == Action { + _Sequence( + _Sequence( + _Sequence( + _Sequence( + _Sequence(_Sequence(_Sequence(_Sequence(_Sequence(r0, r1), r2), r3), r4), r5), r6 + ), + r7 + ), + r8 + ), + r9 + ) + } + + @inlinable + public static func buildEither( + first reducer: R0 + ) -> _Conditional + where R0.State == State, R0.Action == Action { + .first(reducer) + } + + @inlinable + public static func buildEither( + second reducer: R1 + ) -> _Conditional + where R1.State == State, R1.Action == Action { + .second(reducer) + } + + @inlinable + public static func buildExpression(_ expression: R) -> R + where R.State == State, R.Action == Action { + expression + } + + @inlinable + public static func buildFinalResult(_ reducer: R) -> R + where R.State == State, R.Action == Action { + reducer + } + + @_disfavoredOverload + @inlinable + public static func buildFinalResult(_ reducer: R) -> Reduce + where R.State == State, R.Action == Action { + Reduce(reducer) + } + + @inlinable + public static func buildLimitedAvailability( + _ wrapped: R + ) -> _Optional + where R.State == State, R.Action == Action { + _Optional(wrapped: wrapped) + } + + @inlinable + public static func buildOptional(_ wrapped: R?) -> _Optional + where R.State == State, R.Action == Action { + _Optional(wrapped: wrapped) + } +#endif + + public enum _Conditional: ReducerProtocol + where + First.State == Second.State, + First.Action == Second.Action + { + case first(First) + case second(Second) + + @inlinable + public func reduce(into state: inout First.State, action: First.Action) -> Effect< + First.Action + > { + switch self { + case let .first(first): + return first.reduce(into: &state, action: action) + + case let .second(second): + return second.reduce(into: &state, action: action) + } + } + } + + public struct _Optional: ReducerProtocol { + @usableFromInline + let wrapped: Wrapped? + + @usableFromInline + init(wrapped: Wrapped?) { + self.wrapped = wrapped + } + + @inlinable + public func reduce( + into state: inout Wrapped.State, action: Wrapped.Action + ) -> Effect { + switch wrapped { + case let .some(wrapped): + return wrapped.reduce(into: &state, action: action) + case .none: + return .none + } + } + } + + public struct _Sequence: ReducerProtocol + where R0.State == R1.State, R0.Action == R1.Action { + @usableFromInline + let r0: R0 + + @usableFromInline + let r1: R1 + + @usableFromInline + init(_ r0: R0, _ r1: R1) { + self.r0 = r0 + self.r1 = r1 + } + + @inlinable + public func reduce(into state: inout R0.State, action: R0.Action) -> Effect { + self.r0.reduce(into: &state, action: action) + .merge(with: self.r1.reduce(into: &state, action: action)) + } + } + + public struct _SequenceMany: ReducerProtocol { + @usableFromInline + let reducers: [Element] + + @usableFromInline + init(reducers: [Element]) { + self.reducers = reducers + } + + @inlinable + public func reduce( + into state: inout Element.State, action: Element.Action + ) -> Effect { + self.reducers.reduce(.none) { $0.merge(with: $1.reduce(into: &state, action: action)) } + } + } +} + +public typealias ReducerBuilderOf = ReducerBuilder diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/CombineReducers.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/CombineReducers.swift new file mode 100644 index 0000000..160bc34 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/CombineReducers.swift @@ -0,0 +1,55 @@ +/// Combines multiple reducers into a single reducer. +/// +/// `CombineReducers` takes a block that can combine a number of reducers using a +/// ``ReducerBuilder``. +/// +/// Useful for grouping reducers together and applying reducer modifiers to the result. +/// +/// ```swift +/// var body: some ReducerProtocol { +/// CombineReducers { +/// ReducerA() +/// ReducerB() +/// ReducerC() +/// } +/// .ifLet(\.child, action: /Action.child) +/// } +/// ``` +public struct CombineReducers: ReducerProtocol { + @usableFromInline + let reducers: Reducers + + /// Initializes a reducer that combines all of the reducers in the given build block. + /// + /// - Parameter build: A reducer builder. + @inlinable + public init( + @ReducerBuilderOf _ build: () -> Reducers + ) { + self.init(internal: build()) + } + + @usableFromInline + init(internal reducers: Reducers) { + self.reducers = reducers + } + + @inlinable + public func reduce( + into state: inout Reducers.State, action: Reducers.Action + ) -> Effect { + self.reducers.reduce(into: &state, action: action) + } +} + +#if swift(>=5.7) + extension ReducerProtocol { + // NB: This overload is provided to work around https://github.com/apple/swift/issues/60445 + /// Combines multiple reducers into a single reducer. + public func CombineReducers( + @ReducerBuilder _ build: () -> some ReducerProtocol + ) -> some ReducerProtocol { + RxComposableArchitecture.CombineReducers(build) + } + } +#endif diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/DebugReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/DebugReducer.swift new file mode 100644 index 0000000..7347500 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/DebugReducer.swift @@ -0,0 +1,86 @@ +extension ReducerProtocol { + /// Enhances a reducer with debug logging of received actions and state mutations for the given + /// printer. + /// + /// > Note: Printing is only done in `DEBUG` configurations. + /// + /// - Parameter printer: A printer for printing debug messages. + /// - Returns: A reducer that prints debug messages for all received actions. + @inlinable + public func _printChanges( + _ printer: _ReducerPrinter? = .customDump + ) -> _PrintChangesReducer { + _PrintChangesReducer(base: self, printer: printer) + } +} + +public struct _ReducerPrinter { + private let _printChange: + (_ receivedAction: Action, _ oldState: State, _ newState: State) -> Void + + public init( + printChange: @escaping (_ receivedAction: Action, _ oldState: State, _ newState: State) -> + Void + ) { + self._printChange = printChange + } + + public func printChange(receivedAction: Action, oldState: State, newState: State) { + self._printChange(receivedAction, oldState, newState) + } +} + +extension _ReducerPrinter { + public static var customDump: Self { + Self { receivedAction, oldState, newState in + var target = "" + target.write("received action:\n") + RxComposableArchitecture.customDump(receivedAction, to: &target, indent: 2) + target.write("\n") + target.write(diff(oldState, newState).map { "\($0)\n" } ?? " (No state changes)\n") + print(target) + } + } + + public static var actionLabels: Self { + Self { receivedAction, _, _ in + print("received action: \(debugCaseOutput(receivedAction))") + } + } +} + +public struct _PrintChangesReducer: ReducerProtocol { + @usableFromInline + let base: Base + + @usableFromInline + let printer: _ReducerPrinter? + + @usableFromInline + init(base: Base, printer: _ReducerPrinter?) { + self.base = base + self.printer = printer + } + + @usableFromInline + @Dependency(\.context) var context + + @inlinable + public func reduce( + into state: inout Base.State, action: Base.Action + ) -> Effect { + #if DEBUG + if self.context != .test, let printer = self.printer { + let oldState = state + let effects = self.base.reduce(into: &state, action: action) + return effects.merge( + with: .fireAndForget { [newState = state] in + printer.printChange( + receivedAction: action, oldState: oldState, newState: newState) + } + ) + } + #endif + return self.base.reduce(into: &state, action: action) + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift new file mode 100644 index 0000000..4e1c3cb --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/DependencyKeyWritingReducer.swift @@ -0,0 +1,177 @@ +extension ReducerProtocol { + /// Sets the dependency value of the specified key path to the given value. + /// + /// This overrides the dependency specified by `keyPath` for the execution of the receiving + /// reducer _and_ all of its effects. It can be useful for altering the dependencies for just + /// one portion of your application, while letting the rest of the application continue using the + /// default live dependencies. + /// + /// For example, suppose you are creating an onboarding experience to teach people how to use one + /// of your features. This can be done by constructing a new reducer that embeds the core + /// feature's domain and layers on additional logic: + /// + /// ```swift + /// struct Onboarding: ReducerProtocol { + /// struct State { + /// var feature: Feature.State + /// // Additional onboarding state + /// } + /// enum Action { + /// case feature(Feature.Action) + /// // Additional onboarding actions + /// } + /// + /// var body: some ReducerProtocol { + /// Scope(state: \.feature, action: /Action.feature) { + /// Feature() + /// } + /// + /// Reduce { state, action in + /// // Additional onboarding logic + /// } + /// } + /// } + /// ``` + /// + /// This can work just fine, but the `Feature` reducer will have access to all of the live + /// dependencies by default, and that might not be ideal. For example, the `Feature` reducer + /// may need to make API requests and read/write from user defaults. It may be preferable + /// to run the `Feature` reducer in an alternative environment for onboarding purposes, such + /// as an API client that returns some mock data or an in-memory user defaults so that the + /// onboarding experience doesn't accidentally trample on shared data. + /// + /// This can be by using the ``dependency(_:_:)`` method to override those dependencies + /// just for the `Feature` reducer and its effects: + /// + /// ```swift + /// var body: some ReducerProtocol { + /// Scope(state: \.feature, action: /Action.feature) { + /// Feature() + /// .dependency(\.apiClient, .mock) + /// .dependency(\.userDefaults, .mock) + /// } + /// + /// Reduce { state, action in + /// // Additional onboarding logic + /// } + /// } + /// ``` + /// + /// See ``transformDependency(_:transform:)`` for a similar method that can inspect and modify the + /// current dependency when overriding. + /// + /// - Parameters: + /// - keyPath: A key path that indicates the property of the `DependencyValues` structure to + /// update. + /// - value: The new value to set for the item specified by `keyPath`. + /// - Returns: A reducer that has the given value set in its dependencies. + @inlinable + public func dependency( + _ keyPath: WritableKeyPath, + _ value: Value + ) + // NB: We should not return `some ReducerProtocol` here. That would prevent the + // specialization defined below from being called, which fuses chained calls. + -> _DependencyKeyWritingReducer + { + _DependencyKeyWritingReducer(base: self) { $0[keyPath: keyPath] = value } + } + + /// Transform a reducer's dependency value at the specified key path with the given function. + /// + /// This is similar to ``dependency(_:_:)``, except it allows you to mutate a dependency value + /// directly. This can be handy when you want to alter a dependency but still use its current + /// value. + /// + /// For example, suppose you want to see when a particular endpoint of a dependency gets called + /// in your application. You can override that endpoint to insert a breakpoint or print state, + /// but still call out to the original endpoint: + /// + /// ```swift + /// Feature() + /// .transformDependency(\.speechClient) { speechClient in + /// speechClient.requestAuthorization = { + /// print("requestAuthorization") + /// try await speechClient.requestAuthorization() + /// } + /// } + /// ``` + /// + /// You can also transform _all_ dependency values at once by using the `\.self` key path: + /// + /// ```swift + /// Feature() + /// .transformDependency(\.self) { dependencyValues in + /// // Access to all dependencies in here + /// } + /// ``` + /// + /// > Warning: The trailing closure of ``transformDependency(_:transform:)`` is called for every + /// action sent to the reducer, and so you can expect it to be called many times in an + /// application's lifecycle. This means you should typically not create dependencies in the + /// closure as that will cause a new dependency to be created everytime an action is sent. + /// + /// - Parameters: + /// - keyPath: A key path that indicates the property of the `DependencyValues` structure to + /// transform. + /// - transform: A closure that is handed a mutable instance of the value specified by the key + /// path. + @inlinable + public func transformDependency( + _ keyPath: WritableKeyPath, + transform: @escaping (inout V) -> Void + ) + // NB: We should not return `some ReducerProtocol` here. That would prevent the + // specialization defined below from being called, which fuses chained calls. + -> _DependencyKeyWritingReducer + { + _DependencyKeyWritingReducer(base: self) { transform(&$0[keyPath: keyPath]) } + } +} + +public struct _DependencyKeyWritingReducer: ReducerProtocol { + @usableFromInline + let base: Base + + @usableFromInline + let update: (inout DependencyValues) -> Void + + @usableFromInline + init(base: Base, update: @escaping (inout DependencyValues) -> Void) { + self.base = base + self.update = update + } + + @inlinable + public func reduce( + into state: inout Base.State, action: Base.Action + ) -> Effect { + DependencyValues.withValues { + self.update(&$0) + } operation: { + self.base.reduce(into: &state, action: action) + } + } + + @inlinable + public func dependency( + _ keyPath: WritableKeyPath, + _ value: Value + ) -> Self { + Self(base: self.base) { values in + values[keyPath: keyPath] = value + self.update(&values) + } + } + + @inlinable + public func transformDependency( + _ keyPath: WritableKeyPath, + transform: @escaping (inout V) -> Void + ) -> Self { + Self(base: self.base) { values in + transform(&values[keyPath: keyPath]) + self.update(&values) + } + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/EmptyReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/EmptyReducer.swift new file mode 100644 index 0000000..07d7ee3 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/EmptyReducer.swift @@ -0,0 +1,19 @@ +/// A reducer that does nothing. +/// +/// While not very useful on its own, `EmptyReducer` can be used as a placeholder in APIs that hold +/// reducers. +public struct EmptyReducer: ReducerProtocol { + /// Initializes a reducer that does nothing. + @inlinable + public init() { + self.init(internal: ()) + } + + @usableFromInline + init(internal: Void) {} + + @inlinable + public func reduce(into _: inout State, action _: Action) -> Effect { + .none + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/ForEachReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/ForEachReducer.swift new file mode 100644 index 0000000..5759da5 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/ForEachReducer.swift @@ -0,0 +1,160 @@ +extension ReducerProtocol { + /// Embeds a child reducer in a parent domain that works on elements of a collection in parent + /// state. + /// + /// For example, if a parent feature holds onto an array of child states, then it can perform + /// its core logic _and_ the child's logic by using the `forEach` operator: + /// + /// ```swift + /// struct Parent: ReducerProtocol { + /// struct State { + /// var rows: IdentifiedArrayOf + /// // ... + /// } + /// enum Action { + /// case row(id: Row.State.ID, action: Row.Action) + /// // ... + /// } + /// + /// var body: some ReducerProtocol { + /// Reduce { state, action in + /// // Core logic for parent feature + /// } + /// .forEach(\.rows, action: /Action.row) { + /// Row() + /// } + /// } + /// } + /// ``` + /// + /// > Tip: We are using `IdentifiedArray` from our + /// [Identified Collections][swift-identified-collections] library because it provides a safe + /// and ergonomic API for accessing elements from a stable ID rather than positional indices. + /// + /// The `forEach` forces a specific order of operations for the child and parent features. It + /// runs the child first, and then the parent. If the order was reversed, then it would be + /// possible for the parent feature to remove the child state from the array, in which case the + /// child feature would not be able to react to that action. That can cause subtle bugs. + /// + /// It is still possible for a parent feature higher up in the application to remove the child + /// state from the array before the child has a chance to react to the action. In such cases a + /// runtime warning is shown in Xcode to let you know that there's a potential problem. + /// + /// [swift-identified-collections]: http://github.com/pointfreeco/swift-identified-collections + /// + /// - Parameters: + /// - toElementsState: A writable key path from parent state to an `IdentifiedArray` of child + /// state. + /// - toElementAction: A case path from parent action to child identifier and child actions. + /// - element: A reducer that will be invoked with child actions against elements of child + /// state. + /// - Returns: A reducer that combines the child reducer with the parent reducer. + @inlinable + public func forEach( + _ toElementsState: WritableKeyPath>, + action toElementAction: CasePath, + @ReducerBuilderOf _ element: () -> Element, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> _ForEachReducer { + _ForEachReducer( + parent: self, + toElementsState: toElementsState, + toElementAction: toElementAction, + element: element(), + file: file, + fileID: fileID, + line: line + ) + } +} + +public struct _ForEachReducer< + Parent: ReducerProtocol, ID: Hashable, Element: ReducerProtocol +>: ReducerProtocol { + @usableFromInline + let parent: Parent + + @usableFromInline + let toElementsState: WritableKeyPath> + + @usableFromInline + let toElementAction: CasePath + + @usableFromInline + let element: Element + + @usableFromInline + let file: StaticString + + @usableFromInline + let fileID: StaticString + + @usableFromInline + let line: UInt + + @usableFromInline + init( + parent: Parent, + toElementsState: WritableKeyPath>, + toElementAction: CasePath, + element: Element, + file: StaticString, + fileID: StaticString, + line: UInt + ) { + self.parent = parent + self.toElementsState = toElementsState + self.toElementAction = toElementAction + self.element = element + self.file = file + self.fileID = fileID + self.line = line + } + + @inlinable + public func reduce( + into state: inout Parent.State, action: Parent.Action + ) -> Effect { + self.reduceForEach(into: &state, action: action) + .merge(with: self.parent.reduce(into: &state, action: action)) + } + + @inlinable + func reduceForEach( + into state: inout Parent.State, action: Parent.Action + ) -> Effect { + guard let (id, elementAction) = self.toElementAction.extract(from: action) else { return .none } + if state[keyPath: self.toElementsState][id: id] == nil { + runtimeWarn( + """ + A "forEach" at "\(self.fileID):\(self.line)" received an action for a missing element. + + Action: + \(debugCaseOutput(action)) + + This is generally considered an application logic error, and can happen for a few reasons: + + • A parent reducer removed an element with this ID before this reducer ran. This reducer \ + must run before any other reducer removes an element, which ensures that element reducers \ + can handle their actions while their state is still available. + + • An in-flight effect emitted this action when state contained no element at this ID. \ + While it may be perfectly reasonable to ignore this action, consider canceling the \ + associated effect before an element is removed, especially if it is a long-living effect. + + • This action was sent to the store while its state contained no element at this ID. To \ + fix this make sure that actions for this reducer can only be sent from a view store when \ + its state contains an element at this id. In SwiftUI applications, use "ForEachStore". + """, + file: self.file, + line: self.line + ) + return .none + } + return self.element + .reduce(into: &state[keyPath: self.toElementsState][id: id]!, action: elementAction) + .map { self.toElementAction.embed((id, $0)) } + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift new file mode 100644 index 0000000..4ba4a59 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/IfCaseLetReducer.swift @@ -0,0 +1,159 @@ +extension ReducerProtocol { + /// Embeds a child reducer in a parent domain that works on a case of parent enum state. + /// + /// For example, if a parent feature's state is expressed as an enum of multiple children + /// states, then `ifCaseLet` can run a child reducer on a particular case of the enum: + /// + /// ```swift + /// struct Parent: ReducerProtocol { + /// enum State { + /// case loggedIn(Authenticated.State) + /// case loggedOut(Unauthenticated.State) + /// } + /// enum Action { + /// case loggedIn(Authenticated.Action) + /// case loggedOut(Unauthenticated.Action) + /// // ... + /// } + /// + /// var body: some ReducerProtocol { + /// Reduce { state, action in + /// // Core logic for parent feature + /// } + /// .ifCaseLet(/State.loggedIn, action: /Action.loggedIn) { + /// Authenticated() + /// } + /// .ifCaseLet(/State.loggedOut, action: /Action.loggedOut) { + /// Unauthenticated() + /// } + /// } + /// } + /// ``` + /// + /// The `ifCaseLet` forces a specific order of operations for the child and parent features. It + /// runs the child first, and then the parent. If the order was reversed, then it would be + /// possible for the parent feature to change the case of the enum, in which case the child + /// feature would not be able to react to that action. That can cause subtle bugs. + /// + /// It is still possible for a parent feature higher up in the application to change the case of + /// the enum before the child has a chance to react to the action. In such cases a runtime + /// warning is shown in Xcode to let you know that there's a potential problem. + /// + /// - Parameters: + /// - toCaseState: A case path from parent state to a case containing child state. + /// - toCaseAction: A case path from parent action to a case containing child actions. + /// - case: A reducer that will be invoked with child actions against child state when it is + /// present + /// - Returns: A reducer that combines the child reducer with the parent reducer. + @inlinable + public func ifCaseLet( + _ toCaseState: CasePath, + action toCaseAction: CasePath, + @ReducerBuilderOf then case: () -> Case, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> _IfCaseLetReducer { + .init( + parent: self, + child: `case`(), + toChildState: toCaseState, + toChildAction: toCaseAction, + file: file, + fileID: fileID, + line: line + ) + } +} + +public struct _IfCaseLetReducer: ReducerProtocol { + @usableFromInline + let parent: Parent + + @usableFromInline + let child: Child + + @usableFromInline + let toChildState: CasePath + + @usableFromInline + let toChildAction: CasePath + + @usableFromInline + let file: StaticString + + @usableFromInline + let fileID: StaticString + + @usableFromInline + let line: UInt + + @usableFromInline + init( + parent: Parent, + child: Child, + toChildState: CasePath, + toChildAction: CasePath, + file: StaticString, + fileID: StaticString, + line: UInt + ) { + self.parent = parent + self.child = child + self.toChildState = toChildState + self.toChildAction = toChildAction + self.file = file + self.fileID = fileID + self.line = line + } + + @inlinable + public func reduce( + into state: inout Parent.State, action: Parent.Action + ) -> Effect { + self.reduceChild(into: &state, action: action) + .merge(with: self.parent.reduce(into: &state, action: action)) + } + + @inlinable + func reduceChild( + into state: inout Parent.State, action: Parent.Action + ) -> Effect { + guard let childAction = self.toChildAction.extract(from: action) + else { return .none } + guard var childState = self.toChildState.extract(from: state) else { + runtimeWarn( + """ + An "ifCaseLet" at "\(self.fileID):\(self.line)" received a child action when child state \ + was set to a different case. … + + Action: + \(debugCaseOutput(action)) + State: + \(debugCaseOutput(state)) + + This is generally considered an application logic error, and can happen for a few reasons: + + • A parent reducer set "\(typeName(Parent.State.self))" to a different case before this \ + reducer ran. This reducer must run before any other reducer sets child state to a \ + different case. This ensures that child reducers can handle their actions while their \ + state is still available. + + • An in-flight effect emitted this action when child state was unavailable. While it may \ + be perfectly reasonable to ignore this action, consider canceling the associated effect \ + before child state changes to another case, especially if it is a long-living effect. + + • This action was sent to the store while state was another case. Make sure that actions \ + for this reducer can only be sent from a view store when state is set to the appropriate \ + case. In SwiftUI applications, use "SwitchStore". + """, + file: self.file, + line: self.line + ) + return .none + } + defer { state = self.toChildState.embed(childState) } + return self.child.reduce(into: &childState, action: childAction) + .map(self.toChildAction.embed) + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/IfLetReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/IfLetReducer.swift new file mode 100644 index 0000000..e77f030 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/IfLetReducer.swift @@ -0,0 +1,152 @@ +extension ReducerProtocol { + /// Embeds a child reducer in a parent domain that works on an optional property of parent state. + /// + /// For example, if a parent feature holds onto a piece of optional child state, then it can + /// perform its core logic _and_ the child's logic by using the `ifLet` operator: + /// + /// ```swift + /// struct Parent: ReducerProtocol { + /// struct State { + /// var child: Child.State? + /// // ... + /// } + /// enum Action { + /// case child(Child.Action) + /// // ... + /// } + /// + /// var body: some ReducerProtocol { + /// Reduce { state, action in + /// // Core logic for parent feature + /// } + /// .ifLet(\.child, action: /Action.child) { + /// Child() + /// } + /// } + /// } + /// ``` + /// + /// The `ifLet` forces a specific order of operations for the child and parent features. It runs + /// the child first, and then the parent. If the order was reversed, then it would be possible for + /// the parent feature to `nil` out the child state, in which case the child feature would not be + /// able to react to that action. That can cause subtle bugs. + /// + /// It is still possible for a parent feature higher up in the application to `nil` out child + /// state before the child has a chance to react to the action. In such cases a runtime warning + /// is shown in Xcode to let you know that there's a potential problem. + /// + /// - Parameters: + /// - toWrappedState: A writable key path from parent state to a property containing optional + /// child state. + /// - toWrappedAction: A case path from parent action to a case containing child actions. + /// - wrapped: A reducer that will be invoked with child actions against non-optional child + /// state. + /// - Returns: A reducer that combines the child reducer with the parent reducer. + @inlinable + public func ifLet( + _ toWrappedState: WritableKeyPath, + action toWrappedAction: CasePath, + @ReducerBuilderOf then wrapped: () -> Wrapped, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) -> _IfLetReducer { + .init( + parent: self, + child: wrapped(), + toChildState: toWrappedState, + toChildAction: toWrappedAction, + file: file, + fileID: fileID, + line: line + ) + } +} + +public struct _IfLetReducer: ReducerProtocol { + @usableFromInline + let parent: Parent + + @usableFromInline + let child: Child + + @usableFromInline + let toChildState: WritableKeyPath + + @usableFromInline + let toChildAction: CasePath + + @usableFromInline + let file: StaticString + + @usableFromInline + let fileID: StaticString + + @usableFromInline + let line: UInt + + @usableFromInline + init( + parent: Parent, + child: Child, + toChildState: WritableKeyPath, + toChildAction: CasePath, + file: StaticString, + fileID: StaticString, + line: UInt + ) { + self.parent = parent + self.child = child + self.toChildState = toChildState + self.toChildAction = toChildAction + self.file = file + self.fileID = fileID + self.line = line + } + + @inlinable + public func reduce( + into state: inout Parent.State, action: Parent.Action + ) -> Effect { + self.reduceChild(into: &state, action: action) + .merge(with: self.parent.reduce(into: &state, action: action)) + } + + @inlinable + func reduceChild( + into state: inout Parent.State, action: Parent.Action + ) -> Effect { + guard let childAction = self.toChildAction.extract(from: action) + else { return .none } + guard state[keyPath: self.toChildState] != nil else { + runtimeWarn( + """ + An "ifLet" at "\(self.fileID):\(self.line)" received a child action when child state was \ + "nil". … + + Action: + \(debugCaseOutput(action)) + + This is generally considered an application logic error, and can happen for a few reasons: + + • A parent reducer set child state to "nil" before this reducer ran. This reducer must \ + run before any other reducer sets child state to "nil". This ensures that child reducers \ + can handle their actions while their state is still available. + + • An in-flight effect emitted this action when child state was "nil". While it may be \ + perfectly reasonable to ignore this action, consider canceling the associated effect \ + before child state becomes "nil", especially if it is a long-living effect. + + • This action was sent to the store while state was "nil". Make sure that actions for this \ + reducer can only be sent from a view store when state is non-"nil". In SwiftUI \ + applications, use "IfLetStore". + """, + file: self.file, + line: self.line + ) + return .none + } + return self.child.reduce(into: &state[keyPath: self.toChildState]!, action: childAction) + .map(self.toChildAction.embed) + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/OptionalReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/OptionalReducer.swift new file mode 100644 index 0000000..b60e2a6 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/OptionalReducer.swift @@ -0,0 +1,19 @@ +extension Optional: ReducerProtocol where Wrapped: ReducerProtocol { + #if swift(<5.7) + public typealias State = Wrapped.State + public typealias Action = Wrapped.Action + public typealias _Body = Never + #endif + + @inlinable + public func reduce( + into state: inout Wrapped.State, action: Wrapped.Action + ) -> Effect { + switch self { + case let .some(wrapped): + return wrapped.reduce(into: &state, action: action) + case .none: + return .none + } + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/Reduce.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/Reduce.swift new file mode 100644 index 0000000..0f4ca31 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/Reduce.swift @@ -0,0 +1,37 @@ +/// A type-erased reducer that invokes the given `reduce` function. +/// +/// ``Reduce`` is useful for injecting logic into a reducer tree without the overhead of introducing +/// a new type that conforms to ``ReducerProtocol``. +public struct Reduce: ReducerProtocol { + @usableFromInline + let reduce: (inout State, Action) -> Effect + + @usableFromInline + init( + internal reduce: @escaping (inout State, Action) -> Effect + ) { + self.reduce = reduce + } + + /// Initializes a reducer with a `reduce` function. + /// + /// - Parameter reduce: A function that is called when ``reduce(into:action:)`` is invoked. + @inlinable + public init(_ reduce: @escaping (inout State, Action) -> Effect) { + self.init(internal: reduce) + } + + /// Type-erases a reducer. + /// + /// - Parameter reducer: A reducer that is called when ``reduce(into:action:)`` is invoked. + @inlinable + public init(_ reducer: R) + where R.State == State, R.Action == Action { + self.init(internal: reducer.reduce) + } + + @inlinable + public func reduce(into state: inout State, action: Action) -> Effect { + self.reduce(&state, action) + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/Scope.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/Scope.swift new file mode 100644 index 0000000..78153d6 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/Scope.swift @@ -0,0 +1,302 @@ +/// Embeds a child reducer in a parent domain. +/// +/// ``Scope`` allows you to transform a parent domain into a child domain, and then run a child +/// reduce on that subset domain. This is an important tool for breaking down large features into +/// smaller units and then piecing them together. The smaller units can easier to understand and +/// test, and can even be packaged into their own isolated modules. +/// +/// You hand ``Scope`` 3 pieces of data for it to do its job: +/// +/// * A writable key path that identifies the child state inside the parent state. +/// * A case path that identifies the child actions inside the parent actions. +/// * A @``ReducerBuilder`` closure that describes the reducer you want to run on the child domain. +/// +/// When run, it will intercept all child actions sent and feed them to the child reducer so that +/// it can update the parent state and execute effects. +/// +/// For example, given the basic scaffolding of child reducer: +/// +/// ```swift +/// struct Child: ReducerProtocol { +/// struct State { +/// // ... +/// } +/// enum Action { +/// // ... +/// } +/// // ... +/// } +/// ``` +/// +/// A parent reducer with a domain that holds onto the child domain can use +/// ``init(state:action:_:)`` to embed the child reducer in its +/// ``ReducerProtocol/body-swift.property-7foai``: +/// +/// ```swift +/// struct Parent: ReducerProtocol { +/// struct State { +/// var child: Child.State +/// // ... +/// } +/// +/// enum Action { +/// case child(Child.Action) +/// // ... +/// } +/// +/// var body: some ReducerProtocol { +/// Scope(state: \.child, action: /Action.child) { +/// Child() +/// } +/// Reduce { state, action in +/// // Additional parent logic and behavior +/// } +/// } +/// } +/// ``` +/// +/// ## Enum state +/// +/// The ``Scope`` reducer also works when state is modeled as an enum, not just a struct. In that +/// case you can use ``init(state:action:_:file:fileID:line:)`` to specify a case path that +/// identifies the case of state you want to scope to. +/// +/// For example, if your state was modeled as an enum for unloaded/loading/loaded, you could +/// scope to the loaded case to run a reduce on only that case: +/// +/// ```swift +/// struct Feature: ReducerProtocol { +/// enum State { +/// case unloaded +/// case loading +/// case loaded(Child.State) +/// } +/// enum Action { +/// case child(Child.Action) +/// // ... +/// } +/// +/// var body: some ReducerProtocol { +/// Scope(state: /State.loaded, action: /Action.child) { +/// Child() +/// } +/// Reduce { state, action in +/// // Additional feature logic and behavior +/// } +/// } +/// } +/// ``` +/// +/// It is important to note that the order of combine ``Scope`` and your additional feature logic +/// matters. It must be combined before the additional logic. In the other order it would be +/// possible for the feature to intercept a child action, switch the state to another case, and +/// then the scoped child reducer would not be able to react to that action. That can cause subtle +/// bugs, and so we show a runtime warning in that case, and cause test failures. +/// +/// For an alternative to using ``Scope`` with state case paths that enforces the order, check out +/// the ``ifCaseLet(_:action:then:file:fileID:line:)`` operator. +public struct Scope: ReducerProtocol { + public enum StatePath { + case keyPath(WritableKeyPath) + case optionalPath( + OptionalPath, + file: StaticString, + fileID: StaticString, + line: UInt + ) + } + + public let toChildState: StatePath + public let toChildAction: CasePath + public let child: Child + + @usableFromInline + init( + toChildState: StatePath, + toChildAction: CasePath, + child: Child + ) { + self.toChildState = toChildState + self.toChildAction = toChildAction + self.child = child + } + + /// Initializes a reducer that runs the given child reducer against a slice of parent state and + /// actions. + /// + /// Useful for combining child reducers into a parent. + /// + /// ```swift + /// var body: some ReducerProtocol { + /// Scope(state: \.profile, action: /Action.profile) { + /// Profile() + /// } + /// Scope(state: \.settings, action: /Action.settings) { + /// Settings() + /// } + /// // ... + /// } + /// ``` + /// + /// - Parameters: + /// - toChildState: A writable key path from parent state to a property containing child state. + /// - toChildAction: A case path from parent action to a case containing child actions. + /// - child: A reducer that will be invoked with child actions against child state. + @inlinable + public init( + state toChildState: WritableKeyPath, + action toChildAction: CasePath, + @ReducerBuilderOf _ child: () -> Child + ) { + self.init( + toChildState: .keyPath(toChildState), + toChildAction: toChildAction, + child: child() + ) + } + + @inlinable + public init( + state toChildState: OptionalPath, + action toChildAction: CasePath, + @ReducerBuilderOf _ child: () -> Child, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + toChildState: .optionalPath(toChildState, file: file, fileID: fileID, line: line), + toChildAction: toChildAction, + child: child() + ) + } + + /// Initializes a reducer that runs the given child reducer against a slice of parent state and + /// actions. + /// + /// Useful for combining reducers of mutually-exclusive enum state. + /// + /// ```swift + /// var body: some ReducerProtocol { + /// Scope(state: /State.loggedIn, action: /Action.loggedIn) { + /// LoggedIn() + /// } + /// Scope(state: /State.loggedOut, action: /Action.loggedOut) { + /// LoggedOut() + /// } + /// } + /// ``` + /// + /// > Warning: Be careful when assembling reducers that are scoped to cases of enum state. If a + /// > scoped reducer receives a child action when its state is set to an unrelated case, it will + /// > not be able to process the action, which is considered an application logic error and will + /// > emit runtime warnings. + /// > + /// > This can happen if another reducer in the parent domain changes the child state to an + /// > unrelated case when it handles the action _before_ the scoped reducer runs. For example, a + /// > parent may receive a dismissal action from the child domain: + /// > + /// > ```swift + /// > Reduce { state, action in + /// > switch action { + /// > case .loggedIn(.quitButtonTapped): + /// > state = .loggedOut(LoggedOut.State()) + /// > // ... + /// > } + /// > } + /// > Scope(state: /State.loggedIn, action: /Action.loggedIn) { + /// > LoggedIn() // ⚠️ Logged-in domain can't handle `quitButtonTapped` + /// > } + /// > ``` + /// > + /// > If the parent domain contains additional logic for switching between cases of child state, + /// > prefer ``ReducerProtocol/ifCaseLet(_:action:then:file:fileID:line:)``, which better ensures + /// > that child logic runs _before_ any parent logic can replace child state: + /// > + /// > ```swift + /// > Reduce { state, action in + /// > switch action { + /// > case .loggedIn(.quitButtonTapped): + /// > state = .loggedOut(LoggedOut.State()) + /// > // ... + /// > } + /// > } + /// > .ifCaseLet(state: /State.loggedIn, action: /Action.loggedIn) { + /// > LoggedIn() // ✅ Receives actions before its case can change + /// > } + /// > ``` + /// + /// - Parameters: + /// - toChildState: A case path from parent state to a case containing child state. + /// - toChildAction: A case path from parent action to a case containing child actions. + /// - child: A reducer that will be invoked with child actions against child state. + @inlinable + public init( + state toChildState: CasePath, + action toChildAction: CasePath, + @ReducerBuilderOf _ child: () -> Child, + file: StaticString = #file, + fileID: StaticString = #fileID, + line: UInt = #line + ) { + self.init( + toChildState: .optionalPath(OptionalPath(toChildState), file: file, fileID: fileID, line: line), + toChildAction: toChildAction, + child: child() + ) + } + + @inlinable + public func reduce( + into state: inout ParentState, action: ParentAction + ) -> Effect { + guard let childAction = self.toChildAction.extract(from: action) + else { return .none } + switch self.toChildState { + case let .keyPath(toChildState): + return self.child + .reduce(into: &state[keyPath: toChildState], action: childAction) + .map(self.toChildAction.embed) + case let .optionalPath(toChildState, file, fileID, line): + guard var childState = toChildState.extract(from: state) else { + runtimeWarn( + """ + A "Scope" at "\(fileID):\(line)" received a child action when child state was set to a \ + different case. … + + Action: + \(debugCaseOutput(action)) + State: + \(debugCaseOutput(state)) + + This is generally considered an application logic error, and can happen for a few \ + reasons: + + • A parent reducer set "\(typeName(ParentState.self))" to a different case before the \ + scoped reducer ran. Child reducers must run before any parent reducer sets child state \ + to a different case. This ensures that child reducers can handle their actions while \ + their state is still available. Consider using "ReducerProtocol.ifCaseLet" to embed this \ + child reducer in the parent reducer that change its state to ensure the child reducer \ + runs first. + + • An in-flight effect emitted this action when child state was unavailable. While it may \ + be perfectly reasonable to ignore this action, consider canceling the associated effect \ + before child state changes to another case, especially if it is a long-living effect. + + • This action was sent to the store while state was another case. Make sure that actions \ + for this reducer can only be sent from a view store when state is set to the appropriate \ + case. In SwiftUI applications, use "SwitchStore". + """, + file: file, + line: line + ) + return .none + } + defer { toChildState.set(into: &state, childState) } + + return self.child + .reduce(into: &childState, action: childAction) + .map(self.toChildAction.embed) + } + } +} diff --git a/Sources/RxComposableArchitecture/Reducer/Reducers/SignpostReducer.swift b/Sources/RxComposableArchitecture/Reducer/Reducers/SignpostReducer.swift new file mode 100644 index 0000000..55be972 --- /dev/null +++ b/Sources/RxComposableArchitecture/Reducer/Reducers/SignpostReducer.swift @@ -0,0 +1,77 @@ +import OSLog + +extension ReducerProtocol { + /// Instruments a reducer with + /// [signposts](https://developer.apple.com/documentation/os/logging/recording_performance_data). + /// + /// Each invocation of the reducer will be measured by an interval, and the lifecycle of its + /// effects will be measured with interval and event signposts. + /// + /// To use, build your app for profiling, create a blank instrument, and add the signpost + /// instrument. Start recording your app you will see timing information for every action sent to + /// the store, as well as every effect executed. + /// + /// Effect instrumentation can be particularly useful for inspecting the lifecycle of long-living + /// effects. For example, if you start an effect (_e.g._, a location manager) in `onAppear` and + /// forget to tear down the effect in `onDisappear`, the instrument will show that the effect + /// never completed. + /// + /// - Parameters: + /// - prefix: A string to print at the beginning of the formatted message for the signpost. + /// - log: An `OSLog` to use for signposts. + /// - Returns: A reducer that has been enhanced with instrumentation. + @inlinable + public func signpost( + _ prefix: String = "", + log: OSLog = OSLog( + subsystem: "com.tokopedia.RxComposableArchitecture", + category: "Reducer Instrumentation" + ) + ) -> _SignpostReducer { + _SignpostReducer(base: self, prefix: prefix, log: log) + } +} + +public struct _SignpostReducer: ReducerProtocol { + @usableFromInline + let base: Base + + @usableFromInline + let prefix: String + + @usableFromInline + let log: OSLog + + @usableFromInline + init( + base: Base, + prefix: String, + log: OSLog + ) { + self.base = base + // NB: Prevent rendering as "N/A" in Instruments + let zeroWidthSpace = "\u{200B}" + self.prefix = prefix.isEmpty ? zeroWidthSpace : "[\(prefix)] " + self.log = log + } + + @inlinable + public func reduce( + into state: inout Base.State, action: Base.Action + ) -> Effect { + var actionOutput: String! + if self.log.signpostsEnabled { + actionOutput = debugCaseOutput(action) + os_signpost(.begin, log: log, name: "Action", "%s%s", self.prefix, actionOutput) + } + let effects = self.base.reduce(into: &state, action: action) + if self.log.signpostsEnabled { + os_signpost(.end, log: self.log, name: "Action") + return + effects + .effectSignpost(self.prefix, log: self.log, actionOutput: actionOutput) + .eraseToEffect() + } + return effects + } +} diff --git a/Sources/RxComposableArchitecture/ReducerProtocol.swift b/Sources/RxComposableArchitecture/ReducerProtocol.swift new file mode 100644 index 0000000..fdef617 --- /dev/null +++ b/Sources/RxComposableArchitecture/ReducerProtocol.swift @@ -0,0 +1,372 @@ +#if compiler(>=5.7) + /// A protocol that 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. + /// + /// Conform types to this protocol to represent the domain, logic and behavior for your feature. + /// The domain is specified by the "state" and "actions", which can be nested types inside the + /// conformance: + /// + /// ```swift + /// struct Feature: ReducerProtocol { + /// struct State { + /// var count = 0 + /// } + /// enum Action { + /// case decrementButtonTapped + /// case incrementButtonTapped + /// } + /// + /// // ... + /// } + /// ``` + /// + /// The logic of your feature is implemented by mutating the feature's current state when an action + /// comes into the system. This is most easily done by implementing the + /// ``ReducerProtocol/reduce(into:action:)-4nzr2`` method of the protocol. + /// + /// ```swift + /// struct Feature: ReducerProtocol { + /// // ... + /// + /// func reduce(into state: inout State, action: Action) -> Effect { + /// switch action { + /// case .decrementButtonTapped: + /// state.count -= 1 + /// return .none + /// + /// case .incrementButtonTapped: + /// state.count += 1 + /// return .none + /// } + /// } + /// } + /// ``` + /// + /// The `reduce` method's first responsibility is to mutate the feature's current state given an + /// action. It's second responsibility is to return effects that will be executed asynchronously + /// and feed their data back into the system. Currently `Feature` does not need to run any effects, + /// and so ``Effect/none`` is returned. + /// + /// If the feature does need to do effectful work, then more would need to be done. For example, + /// suppose the feature has the ability to start and stop a timer, and with each tick of the timer + /// the `count` was incremented. That could be done like so: + /// + /// ```swift + /// struct Feature: ReducerProtocol { + /// struct State { + /// var count = 0 + /// } + /// enum Action { + /// case decrementButtonTapped + /// case incrementButtonTapped + /// case startTimerButtonTapped + /// case stopTimerButtonTapped + /// case timerTick + /// } + /// enum TimerID {} + /// + /// func reduce(into state: inout State, action: Action) -> Effect { + /// switch action { + /// case .decrementButtonTapped: + /// state.count -= 1 + /// return .none + /// + /// case .incrementButtonTapped: + /// state.count += 1 + /// return .none + /// + /// case startTimerButtonTapped: + /// return .run { send in + /// while true { + /// try await Task.sleep(for: .seconds(1)) + /// await send(.timerTick) + /// } + /// } + /// .cancellable(TimerID.self) + /// + /// case stopTimerButtonTapped: + /// return .cancel(TimerID.self) + /// + /// case timerTick: + /// state.count += 1 + /// return .none + /// } + /// } + /// } + /// ``` + /// + /// > Note: This sample emulates a timer by performing an infinite loop with a `Task.sleep` + /// inside. This is simple to do, but is also inaccurate since small imprecisions can accumulate. + /// It would be better to inject a Combine scheduler into the feature and use its async-friendly + /// `timer` method. Read the and articles for more + /// information. + /// + /// That is the basics of implementing a feature as a conformance to ``ReducerProtocol``. There are + /// actually two ways to define a reducer: + /// + /// 1. You can either implement the ``reduce(into:action:)-4nzr2`` method, as shown above, which + /// is given direct mutable access to application ``State`` whenever an ``Action`` is fed into + /// the system, and returns an ``Effect`` that can communicate with the outside world and feed + /// additional ``Action``s back into the system. + /// + /// 2. Or you can implement the ``body-swift.property-7foai`` property, which combines one or + /// more reducers together. + /// + /// At most one of these requirements should be implemented. If a conformance implements both + /// requirements, only ``reduce(into:action:)-4nzr2`` will be called by the ``Store``. If your + /// reducer assembles a body from other reducers _and_ has additional business logic it needs to + /// layer onto the feature, introduce this logic into the body instead, either with ``Reduce``: + /// + /// ```swift + /// var body: some ReducerProtocol { + /// Reduce { state, action in + /// // extra logic + /// } + /// Activity() + /// Profile() + /// Settings() + /// } + /// ``` + /// + /// …or moving the extra logic to a method that is wrapped in ``Reduce``: + /// + /// ```swift + /// var body: some ReducerProtocol { + /// Reduce(self.core) + /// Activity() + /// Profile() + /// Settings() + /// } + /// + /// func core(state: inout State, action: Action) -> Effect { + /// // extra logic + /// } + /// ``` + /// + /// If you are implementing a custom reducer operator that transforms an existing reducer, + /// _always_ invoke the ``reduce(into:action:)-4nzr2`` method, never the + /// ``body-swift.property-7foai``. For example, this operator that logs all actions sent to the + /// reducer: + /// + /// ```swift + /// extension ReducerProtocol { + /// func logActions() -> some ReducerProtocol { + /// Reduce { state, action in + /// print("Received action: \(action)") + /// return self.reduce(into: &state, action: action) + /// } + /// } + /// } + /// ``` + /// + public protocol ReducerProtocol { + /// A type that holds the current state of the reducer. + associatedtype State + + /// A type that holds all possible actions that cause the ``State`` of the reducer to change + /// and/or kick off a side ``Effect`` that can communicate with the outside world. + associatedtype Action + + // NB: For Xcode to favor autocompleting `var body: Body` over `var body: Never` we must use a + // type alias. + associatedtype _Body + + /// A type representing the body of this reducer. + /// + /// When you create a custom reducer by implementing the ``body-swift.property-7foai``, Swift + /// infers this type from the value returned. + /// + /// If you create a custom reducer by implementing the ``reduce(into:action:)-4nzr2``, Swift + /// infers this type to be `Never`. + typealias Body = _Body + + /// Evolves the current state of the reducer to the next state. + /// + /// Implement this requirement for "primitive" reducers, or reducers that work on leaf node + /// features. To define a reducer by combining the logic of other reducers together, implement + /// the ``body-swift.property-97ymy`` requirement instead. + /// + /// - Parameters: + /// - state: The current state of the reducer. + /// - action: An action that can cause the state of the reducer to change, and/or kick off a + /// side effect that can communicate with the outside world. + /// - Returns: An effect that can communicate with the outside world and feed actions back into + /// the system. + func reduce(into state: inout State, action: Action) -> Effect + + /// The content and behavior of a reducer that is composed from other reducers. + /// + /// Implement this requirement when you want to incorporate the behavior of other reducers + /// together. + /// + /// Do not invoke this property directly. + /// + /// > Important: if your reducer implements the ``reduce(into:action:)-4nzr2`` method, it will + /// > take precedence over this property, and only ``reduce(into:action:)-4nzr2`` will be called + /// > by the ``Store``. If your reducer assembles a body from other reducers and has additional + /// > business logic it needs to layer into the system, introduce this logic into the body + /// > instead, either with ``Reduce``, or with a separate, dedicated conformance. + @ReducerBuilder + var body: Body { get } + } +#else + /// A protocol that 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. + /// + /// There are two ways to define a reducer: + /// + /// 1. You can either implement the ``reduce(into:action:)-4nzr2`` method, which is given direct + /// mutable access to application ``State`` whenever an ``Action`` is fed into the system, + /// and returns an ``Effect`` that can communicate with the outside world and feed additional + /// ``Action``s back into the system. + /// + /// 2. Or you can implement the ``body-swift.property-7foai`` property, which combines one or + /// more reducers together. + /// + /// At most one of these requirements should be implemented. If a conformance implements both + /// requirements, only ``reduce(into:action:)-4nzr2`` will be called by the ``Store``. If your + /// reducer assembles a body from other reducers _and_ has additional business logic it needs to + /// layer onto the feature, introduce this logic into the body instead, either with ``Reduce``: + /// + /// ```swift + /// var body: some ReducerProtocol { + /// Reduce { state, action in + /// // extra logic + /// } + /// Activity() + /// Profile() + /// Settings() + /// } + /// ``` + /// + /// ...or with a separate, dedicated conformance: + /// + /// ```swift + /// var body: some ReducerProtocol { + /// Core() + /// Activity() + /// Profile() + /// Settings() + /// } + /// struct Core: ReducerProtocol { + /// // extra logic + /// } + /// ``` + /// + /// If you are implementing a custom reducer operator that transforms an existing reducer, + /// _always_ invoke the ``reduce(into:action:)-4nzr2`` method, never the + /// ``body-swift.property-7foai``. For example, this operator that logs all actions sent to the + /// reducer: + /// + /// ```swift + /// extension ReducerProtocol { + /// func logActions() -> some ReducerProtocol { + /// Reduce { state, action in + /// print("Received action: \(action)") + /// return self.reduce(into: &state, action: action) + /// } + /// } + /// } + /// ``` + public protocol ReducerProtocol { + /// A type that holds the current state of the reducer. + associatedtype State + + /// A type that holds all possible actions that cause the ``State`` of the reducer to change + /// and/or kick off a side ``Effect`` that can communicate with the outside world. + associatedtype Action + + // NB: For Xcode to favor autocompleting `var body: Body` over `var body: Never` we must use a + // type alias. + associatedtype _Body + + /// A type representing the body of this reducer. + /// + /// When you create a custom reducer by implementing the ``body-swift.property-7foai``, Swift + /// infers this type from the value returned. + /// + /// If you create a custom reducer by implementing the ``reduce(into:action:)-4nzr2``, Swift + /// infers this type to be `Never`. + typealias Body = _Body + + /// Evolves the current state of an reducer to the next state. + /// + /// Implement this requirement for "primitive" reducers, or reducers that work on leaf node + /// features. To define a reducer by combining the logic of other reducers together, implement + /// the ``body-swift.property-7foai`` requirement instead. + /// + /// - Parameters: + /// - state: The current state of the reducer. + /// - action: An action that can cause the state of the reducer to change, and/or kick off + /// a side effect that can communicate with the outside world. + /// - Returns: An effect that can communicate with the outside world and feed actions back into + /// the system. + func reduce(into state: inout State, action: Action) -> Effect + + /// The content and behavior of a reducer that is composed from other reducers. + /// + /// Implement this requirement when you want to incorporate the behavior of other reducers + /// together. + /// + /// Do not invoke this property directly. + /// + /// > Important: if your reducer implements the ``reduce(into:action:)-4nzr2`` method, it will + /// > take precedence over this property, and only ``reduce(into:action:)-4nzr2`` will be called + /// > by the ``Store``. If your reducer assembles a body from other reducers and has additional + /// > business logic it needs to layer into the system, introduce this logic into the body + /// > instead, either with ``Reduce``, or with a separate, dedicated conformance. + @ReducerBuilder + var body: Body { get } + } +#endif + +extension ReducerProtocol where Body == Never { + /// A non-existent body. + /// + /// > Warning: Do not invoke this property directly. It will trigger a fatal error at runtime. + @_transparent + public var body: Body { + fatalError( + """ + '\(Self.self)' has no body. … + + Do not access a reducer's 'body' property directly, as it may not exist. To run a reducer, \ + call 'Reducer.reduce(into:action:)', instead. + """ + ) + } +} + +extension ReducerProtocol where Body: ReducerProtocol, Body.State == State, Body.Action == Action { + /// Invokes the ``Body-40qdd``'s implementation of ``reduce(into:action:)-4nzr2``. + @inlinable + public func reduce( + into state: inout Body.State, action: Body.Action + ) -> Effect { + self.body.reduce(into: &state, action: action) + } +} + +// NB: This is available only in Swift 5.7.1 due to the following bug: +// https://github.com/apple/swift/issues/60550 +#if swift(>=5.7.1) + /// A convenience for constraining a ``ReducerProtocol`` conformance. Available only in Swift + /// 5.7.1. + /// + /// This allows you to specify the `body` of a ``ReducerProtocol`` conformance like so: + /// + /// ```swift + /// var body: some ReducerProtocolOf { + /// // ... + /// } + /// ``` + /// + /// …instead of the more verbose: + /// + /// ```swift + /// var body: some ReducerProtocol { + /// // ... + /// } + /// ``` + public typealias ReducerProtocolOf = ReducerProtocol +#endif diff --git a/Sources/RxComposableArchitecture/Store.swift b/Sources/RxComposableArchitecture/Store.swift index 25ae62c..b90b9ba 100644 --- a/Sources/RxComposableArchitecture/Store.swift +++ b/Sources/RxComposableArchitecture/Store.swift @@ -12,15 +12,19 @@ public final class Store { private var synchronousActionsToSend: [Action] = [] private var bufferedActions: [Action] = [] - private let reducer: (inout State, Action) -> Effect + #if swift(>=5.7) + private let reducer: any ReducerProtocol + #else + private let reducer: (inout State, Action) -> Effect + fileprivate var scope: AnyStoreScope? + #endif internal let disposeBag = DisposeBag() internal var effectDisposables = CompositeDisposable() internal let relay: BehaviorRelay - private let useNewScope: Bool + fileprivate let useNewScope: Bool fileprivate let cancelsEffectsOnDeinit: Bool - fileprivate var scope: AnyScope? #if DEBUG private let mainThreadChecksEnabled: Bool @@ -29,174 +33,355 @@ public final class Store { public var observable: Observable { return relay.asObservable() } - - public init( - initialState: State, - reducer: Reducer, - environment: Environment, + + public init( + initialState: R.State, + reducer: R, useNewScope: Bool = StoreConfig.default.useNewScope(), mainThreadChecksEnabled: Bool = StoreConfig.default.mainThreadChecksEnabled(), cancelsEffectsOnDeinit: Bool = StoreConfig.default.cancelsEffectsOnDeinit() - ) { - relay = BehaviorRelay(value: initialState) - self.reducer = { state, action in reducer.run(&state, action, environment) } - self.useNewScope = useNewScope + ) where R.State == State, R.Action == Action { + self.relay = BehaviorRelay(value: initialState) self.cancelsEffectsOnDeinit = cancelsEffectsOnDeinit - + self.useNewScope = useNewScope + #if swift(>=5.7) + self.reducer = reducer + #else + self.reducer = reducer.reduce + #endif #if DEBUG - self.mainThreadChecksEnabled = mainThreadChecksEnabled + self.mainThreadChecksEnabled = mainThreadChecksEnabled #endif - - state = initialState - self.threadCheck(status: .`init`) + self.state = initialState if cancelsEffectsOnDeinit { - // ties the disposables to the lifetime of the dispose bag for cleanup. effectDisposables.disposed(by: disposeBag) } } - private func newSend(_ action: Action, originatingFrom originatingAction: Action? = nil) { - bufferedActions.append(action) - guard !isSending else { return } + /// Scopes the store to one that exposes child state and actions. + /// + /// This can be useful for deriving new stores to hand to child views in an application. For + /// example: + /// + /// ```swift + /// // Application state made from child states. + /// struct State { var login: LoginState, ... } + /// enum Action { case login(LoginAction), ... } + /// + /// // A store that runs the entire application. + /// let store = Store( + /// initialState: AppReducer.State(), + /// reducer: AppReducer() + /// ) + /// + /// // Construct a login view by scoping the store to one that works with only login domain. + /// LoginView( + /// store: store.scope( + /// state: \.login, + /// action: AppReducer.Action.login + /// ) + /// ) + /// ``` + /// + /// Scoping in this fashion allows you to better modularize your application. In this case, + /// `LoginView` could be extracted to a module that has no access to `App.State` or `App.Action`. + /// + /// Scoping also gives a view the opportunity to focus on just the state and actions it cares + /// about, even if its feature domain is larger. + /// + /// For example, the above login domain could model a two screen login flow: a login form followed + /// by a two-factor authentication screen. The second screen's domain might be nested in the + /// first: + /// + /// ```swift + /// struct LoginState: Equatable { + /// var email = "" + /// var password = "" + /// var twoFactorAuth: TwoFactorAuthState? + /// } + /// + /// enum LoginAction: Equatable { + /// case emailChanged(String) + /// case loginButtonTapped + /// case loginResponse(Result) + /// case passwordChanged(String) + /// case twoFactorAuth(TwoFactorAuthAction) + /// } + /// ``` + /// + /// The login view holds onto a store of this domain: + /// + /// ```swift + /// struct LoginView: View { + /// let store: Store + /// + /// var body: some View { ... } + /// } + /// ``` + /// + /// If its body were to use a view store of the same domain, this would introduce a number of + /// problems: + /// + /// * The login view would be able to read from `twoFactorAuth` state. This state is only intended + /// to be read from the two-factor auth screen. + /// + /// * Even worse, changes to `twoFactorAuth` state would now cause SwiftUI to recompute + /// `LoginView`'s body unnecessarily. + /// + /// * The login view would be able to send `twoFactorAuth` actions. These actions are only + /// intended to be sent from the two-factor auth screen (and reducer). + /// + /// * The login view would be able to send non user-facing login actions, like `loginResponse`. + /// These actions are only intended to be used in the login reducer to feed the results of + /// effects back into the store. + /// + /// To avoid these issues, one can introduce a view-specific domain that slices off the subset of + /// state and actions that a view cares about: + /// + /// ```swift + /// extension LoginView { + /// struct State: Equatable { + /// var email: String + /// var password: String + /// } + /// + /// enum Action: Equatable { + /// case emailChanged(String) + /// case loginButtonTapped + /// case passwordChanged(String) + /// } + /// } + /// ``` + /// + /// One can also introduce a couple helpers that transform feature state into view state and + /// transform view actions into feature actions. + /// + /// ```swift + /// extension LoginState { + /// var view: LoginView.State { + /// .init(email: self.email, password: self.password) + /// } + /// } + /// + /// extension LoginView.Action { + /// var feature: LoginAction { + /// switch self { + /// case let .emailChanged(email) + /// return .emailChanged(email) + /// case .loginButtonTapped: + /// return .loginButtonTapped + /// case let .passwordChanged(password) + /// return .passwordChanged(password) + /// } + /// } + /// } + /// ``` + /// + /// With these helpers defined, `LoginView` can now scope its store's feature domain into its view + /// domain: + /// + /// ```swift + /// var body: some View { + /// WithViewStore( + /// self.store, observe: \.view, send: \.feature + /// ) { viewStore in + /// ... + /// } + /// } + /// ``` + /// + /// This view store is now incapable of reading any state but view state (and will not recompute + /// when non-view state changes), and is incapable of sending any actions but view actions. + /// + /// - Parameters: + /// - toChildState: A function that transforms `State` into `ChildState`. + /// - fromChildAction: A function that transforms `ChildAction` into `Action`. + /// - Returns: A new store with its domain (state and action) transformed. + public func scope( + state toChildState: @escaping (State) -> ChildState, + action fromChildAction: @escaping (ChildAction) -> Action + ) -> Store { + self.threadCheck(status: .scope) + guard self.useNewScope else { + return oldScope(state: toChildState, action: fromChildAction) + } + + #if swift(>=5.7) + return self.reducer.rescope(self, state: toChildState, action: fromChildAction) + #else + return (self.scope ?? StoreScope(root: self)) + .rescope(self, state: toChildState, action: fromChildAction) + #endif + } + + /// Scopes the store to one that exposes child state. + /// + /// A version of ``scope(state:action:)`` that leaves the action type unchanged. + /// + /// - Parameter toChildState: A function that transforms `State` into `ChildState`. + /// - Returns: A new store with its domain (state and action) transformed. + public func scope( + state toChildState: @escaping (State) -> ChildState + ) -> Store { + self.scope(state: toChildState, action: { $0 }) + } + + @inline(__always) + private func oldScope( + state toChildState: @escaping (State) -> ChildState, + action fromChildAction: @escaping (ChildAction) -> Action + ) -> Store { + let localStore = Store( + initialState: toChildState(state), + reducer: AnyReducer { localState, localAction, _ in + self.send(fromChildAction(localAction)) + localState = toChildState(self.state) + return .none + }, + environment: (), + useNewScope: useNewScope + ) + + relay + .subscribe(onNext: { [weak localStore] newValue in + localStore?.state = toChildState(newValue) + }) + .disposed(by: localStore.disposeBag) + + return localStore + } + + @discardableResult + public func send( + _ action: Action, + originatingFrom originatingAction: Action? = nil + ) -> Task? { + guard useNewScope else { + oldSend(action, originatingFrom: originatingAction) + return nil + } + self.threadCheck(status: .send(action, originatingAction: originatingAction)) + + self.bufferedActions.append(action) + guard !self.isSending else { return nil } - isSending = true - var currentState = state + self.isSending = true + var currentState = self.state + let tasks = TaskBox<[Task]>(wrappedValue: []) defer { - self.isSending = false + withExtendedLifetime(self.bufferedActions) { + self.bufferedActions.removeAll() + } self.state = currentState + self.isSending = false + if !self.bufferedActions.isEmpty { + if let task = self.send( + self.bufferedActions.removeLast(), originatingFrom: originatingAction + ) { + tasks.wrappedValue.append(task) + } + } } - while !bufferedActions.isEmpty { - let action = bufferedActions.removeFirst() - let effect = reducer(¤tState, action) - - var didComplete = false - var disposeKey: CompositeDisposable.DisposeKey? + + var index = self.bufferedActions.startIndex + while index < self.bufferedActions.endIndex { + defer { index += 1 } + let action = self.bufferedActions[index] + #if swift(>=5.7) + let effect = self.reducer.reduce(into: ¤tState, action: action) + #else + let effect = self.reducer(¤tState, action) + #endif - let effectDisposable = effect.subscribe( - onNext: { [weak self] effectAction in - self?.send(effectAction, originatingFrom: action) - }, - onError: { err in - assertionFailure("Error during effect handling: \(err.localizedDescription)") - }, - onCompleted: { [weak self] in - self?.threadCheck(status: .effectCompletion(action)) - didComplete = true - if let disposeKey = disposeKey { - self?.effectDisposables.remove(for: disposeKey) + switch effect.operation { + case .none: + break + case let .observable(observable): + var didComplete = false + let boxedTask = TaskBox?>(wrappedValue: nil) + var disposeKey: CompositeDisposable.DisposeKey? + let effectDisposable = observable + .do(onDispose: { [weak self] in + self?.threadCheck(status: .effectCompletion(action)) + if let disposeKey = disposeKey { + self?.effectDisposables.remove(for: disposeKey) + } + }) + .subscribe( + onNext: { [weak self] effectAction in + if let task = self?.send(effectAction, originatingFrom: action) { + tasks.wrappedValue.append(task) + } + }, + onError: { + assertionFailure("Error during effect handling: \($0.localizedDescription)") + }, + onCompleted: { [weak self] in + self?.threadCheck(status: .effectCompletion(action)) + boxedTask.wrappedValue?.cancel() + didComplete = true + if let disposeKey = disposeKey { + self?.effectDisposables.remove(for: disposeKey) + } + } + ) + + if !didComplete { + let task = Task { @MainActor in + for await _ in AsyncStream.never {} + effectDisposable.dispose() } + boxedTask.wrappedValue = task + tasks.wrappedValue.append(task) + disposeKey = effectDisposables.insert(effectDisposable) } - ) - if !didComplete { - disposeKey = effectDisposables.insert(effectDisposable) + case let .run(priority, operation): + tasks.wrappedValue.append( + Task(priority: priority) { + await operation( + Send { + if let task = self.send($0, originatingFrom: action) { + tasks.wrappedValue.append(task) + } + } + ) + } + ) } } - } - - public func send(_ action: Action, originatingFrom originatingAction: Action? = nil) { - self.threadCheck(status: .send(action, originatingAction: originatingAction)) - guard !useNewScope else { - newSend(action, originatingFrom: originatingAction) - return - } - if !isSending { - synchronousActionsToSend.append(action) - } else { - bufferedActions.append(action) - return - } - - while !synchronousActionsToSend.isEmpty || !bufferedActions.isEmpty { - let action = !synchronousActionsToSend.isEmpty - ? synchronousActionsToSend.removeFirst() - : bufferedActions.removeFirst() - - isSending = true - let effect = reducer(&state, action) - isSending = false - - var didComplete = false - var isProcessingEffects = true - var disposeKey: CompositeDisposable.DisposeKey? - - let effectDisposable = effect.subscribe( - onNext: { [weak self] effectAction in - if isProcessingEffects { - self?.synchronousActionsToSend.append(effectAction) - } else { - self?.send(effectAction, originatingFrom: action) - } - }, - onError: { err in - assertionFailure("Error during effect handling: \(err.localizedDescription)") - }, - onCompleted: { [weak self] in - didComplete = true - if let disposeKey = disposeKey { - self?.effectDisposables.remove(for: disposeKey) - } + + guard !tasks.wrappedValue.isEmpty else { return nil } + return Task { + await withTaskCancellationHandler { + var index = tasks.wrappedValue.startIndex + while index < tasks.wrappedValue.endIndex { + defer { index += 1 } + await tasks.wrappedValue[index].value + } + } onCancel: { + var index = tasks.wrappedValue.startIndex + while index < tasks.wrappedValue.endIndex { + defer { index += 1 } + tasks.wrappedValue[index].cancel() } - ) - - isProcessingEffects = false - - if !didComplete { - disposeKey = effectDisposables.insert(effectDisposable) } } } - - public func scope( - state toLocalState: @escaping (State) -> LocalState, - action fromLocalAction: @escaping (LocalAction) -> Action - ) -> Store { - self.threadCheck(status: .scope) - if useNewScope { - return (self.scope ?? Scope(root: self)).rescope(self, state: toLocalState, action: fromLocalAction) - } else { - let localStore = Store( - initialState: toLocalState(state), - reducer: Reducer { localState, localAction, _ in - self.send(fromLocalAction(localAction)) - localState = toLocalState(self.state) - return .none - }, - environment: (), - useNewScope: useNewScope, - mainThreadChecksEnabled: isMainThreadChecksEnabled, - cancelsEffectsOnDeinit: cancelsEffectsOnDeinit - ) - - relay - .subscribe(onNext: { [weak localStore] newValue in - localStore?.state = toLocalState(newValue) - }) - .disposed(by: localStore.disposeBag) - - return localStore - } - } - - public func scope( - state toLocalState: @escaping (State) -> LocalState - ) -> Store { - scope(state: toLocalState, action: { $0 }) - } - + /// Returns a "stateless" store by erasing state to `Void`. public var stateless: Store { - scope(state: { _ in () }) + self.scope(state: { _ in () }) } - + /// Returns an "actionless" store by erasing action to `Never`. public var actionless: Store { - func absurd(_: Never) -> A {} - return scope(state: { $0 }, action: absurd) + func absurd(_ never: Never) -> A {} + return self.scope(state: { $0 }, action: absurd) } - + private enum ThreadCheckStatus { case effectCompletion(Action) case `init` @@ -206,98 +391,82 @@ public final class Store { @inline(__always) private func threadCheck(status: ThreadCheckStatus) { - #if DEBUG + #if DEBUG guard self.mainThreadChecksEnabled && !Thread.isMainThread else { return } switch status { case let .effectCompletion(action): - runtimeWarning( + runtimeWarn( """ An effect completed on a non-main thread. … Effect returned from: - %@ + \(debugCaseOutput(action)) Make sure to use ".receive(on:)" on any effects that execute on background threads to \ - receive their output on the main thread, or create your store via "Store.unchecked" to \ - opt out of the main thread checker. + 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 same \ + "Store" (including all of its scopes and derived view stores) must be done on the main \ thread. - """, - [debugCaseOutput(action)] + """ ) case .`init`: - runtimeWarning( + runtimeWarn( """ A store initialized on a non-main thread. … - If a store is intended to be used on a background thread, create it via \ - "Store.unchecked" to opt out of the main thread checker. - 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 same \ + "Store" (including all of its scopes and derived view stores) must be done on the main \ thread. """ ) case .scope: - runtimeWarning( + runtimeWarn( """ "Store.scope" was called on a non-main thread. … - Make sure to use "Store.scope" on the main thread, or create your store via \ - "Store.unchecked" to opt out of the main thread checker. - 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 same \ + "Store" (including all of its scopes and derived view stores) must be done on the main \ thread. """ ) case let .send(action, originatingAction: nil): - runtimeWarning( + runtimeWarn( """ - "Store.send" was called on a non-main thread with: %@ … - - Make sure that "store.send" is always called on the main thread, or create your \ - store via "Store.unchecked" to opt out of the main thread checker. + "ViewStore.send" was called on a non-main thread with: \(debugCaseOutput(action)) … 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 same \ + "Store" (including all of its scopes and derived view stores) must be done on the main \ thread. - """, - [debugCaseOutput(action)] + """ ) case let .send(action, originatingAction: .some(originatingAction)): - runtimeWarning( + runtimeWarn( """ An effect published an action on a non-main thread. … Effect published: - %@ + \(debugCaseOutput(action)) Effect returned from: - %@ + \(debugCaseOutput(originatingAction)) Make sure to use ".receive(on:)" on any effects that execute on background threads to \ - receive their output on the main thread, or create this store via "Store.unchecked" to \ - disable the main thread checker. + 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 same \ + "Store" (including all of its scopes and derived view stores) must be done on the main \ thread. - """, - [ - debugCaseOutput(action), - debugCaseOutput(originatingAction), - ]) + """ + ) } - #endif + #endif } public func subscribe( @@ -312,6 +481,63 @@ public final class Store { ) -> Effect { return relay.map(toLocalState).distinctUntilChanged().eraseToEffect() } + + @inline(__always) + private func oldSend(_ action: Action, originatingFrom originatingAction: Action? = nil) { + self.threadCheck(status: .send(action, originatingAction: originatingAction)) + if !isSending { + synchronousActionsToSend.append(action) + } else { + bufferedActions.append(action) + return + } + + while !synchronousActionsToSend.isEmpty || !bufferedActions.isEmpty { + let action = !synchronousActionsToSend.isEmpty + ? synchronousActionsToSend.removeFirst() + : bufferedActions.removeFirst() + + isSending = true + #if swift(>=5.7) + let effect = self.reducer.reduce(into: &state, action: action) + #else + let effect = self.reducer(&state, action) + #endif + isSending = false + + var didComplete = false + var isProcessingEffects = true + var disposeKey: CompositeDisposable.DisposeKey? + + switch effect.operation { + case .none, .run: break + case let .observable(observable): + let effectDisposable = observable.subscribe( + onNext: { [weak self] effectAction in + if isProcessingEffects { + self?.synchronousActionsToSend.append(effectAction) + } else { + self?.send(effectAction, originatingFrom: action) + } + }, + onError: { err in + assertionFailure("Error during effect handling: \(err.localizedDescription)") + }, + onCompleted: { [weak self] in + didComplete = true + if let disposeKey = disposeKey { + self?.effectDisposables.remove(for: disposeKey) + } + } + ) + isProcessingEffects = false + + if !didComplete { + disposeKey = effectDisposables.insert(effectDisposable) + } + } + } + } } extension Store { @@ -338,6 +564,23 @@ extension Store where State: Equatable { } } +/// A convenience type alias for referring to a store of a given reducer's domain. +/// +/// Instead of specifying two generics: +/// +/// ```swift +/// let store: Store +/// ``` +/// +/// You can specify a single generic: +/// +/// ```swift +/// let store: StoreOf +/// ``` +public typealias StoreOf = Store + +// MARK: - Old Store function pre reducer protocol +#if swift(<5.7) extension Store where State: Collection, State.Element: HashDiffable, State: Equatable, State.Element: Equatable { /** A version of scope that scope an collection of sub store. @@ -402,9 +645,7 @@ extension Store where State: Collection, State.Element: HashDiffable, State: Equ return .none }, environment: (), - useNewScope: useNewScope, - mainThreadChecksEnabled: isMainThreadChecksEnabled, - cancelsEffectsOnDeinit: cancelsEffectsOnDeinit + useNewScope: useNewScope ) relay @@ -433,9 +674,7 @@ extension Store where State: Collection, State.Element: HashDiffable, State: Equ return .none }, environment: (), - useNewScope: useNewScope, - mainThreadChecksEnabled: isMainThreadChecksEnabled, - cancelsEffectsOnDeinit: cancelsEffectsOnDeinit + useNewScope: useNewScope ) // reflect changes on store parent to local store @@ -458,7 +697,7 @@ extension Store where State: Collection, State.Element: HashDiffable, State: Equ } } -private protocol AnyScope { +private protocol AnyStoreScope { func rescope( _ store: Store, state toRescopedState: @escaping (ScopedState) -> RescopedState, @@ -466,7 +705,7 @@ private protocol AnyScope { ) -> Store } -private struct Scope: AnyScope { +private struct StoreScope: AnyStoreScope { let root: Store let fromScopedAction: Any @@ -500,9 +739,7 @@ private struct Scope: AnyScope { return .none }, environment: (), - useNewScope: true, - mainThreadChecksEnabled: root.isMainThreadChecksEnabled, - cancelsEffectsOnDeinit: root.cancelsEffectsOnDeinit + useNewScope: root.useNewScope ) scopedStore.relay @@ -513,34 +750,194 @@ private struct Scope: AnyScope { }) .disposed(by: rescopedStore.disposeBag) - rescopedStore.scope = Scope( + rescopedStore.scope = StoreScope( root: self.root, fromScopedAction: { fromScopedAction(fromRescopedAction($0)) } ) return rescopedStore } } +#endif + +// MARK: - Reducer protocol Swift >=5.7 +#if swift(>=5.7) +extension ReducerProtocol { + fileprivate func rescope( + _ store: Store, + state toChildState: @escaping (State) -> ChildState, + action fromChildAction: @escaping (ChildAction) -> Action + ) -> Store { + (self as? any AnyScopedReducer ?? ScopedReducer(rootStore: store)) + .rescope(store, state: toChildState, action: fromChildAction) + } +} -public struct StoreConfig { - public var useNewScope: () -> Bool - public var mainThreadChecksEnabled: () -> Bool - public var cancelsEffectsOnDeinit: () -> Bool +private final class ScopedReducer: ReducerProtocol { + let rootStore: Store + let toScopedState: (RootState) -> ScopedState + private let parentStores: [Any] + let fromScopedAction: (ScopedAction) -> RootAction + private(set) var isSending = false + + @inlinable + init(rootStore: Store) + where RootState == ScopedState, RootAction == ScopedAction { + self.rootStore = rootStore + self.toScopedState = { $0 } + self.parentStores = [] + self.fromScopedAction = { $0 } + } - public init( - useNewScope: @escaping () -> Bool, - mainThreadChecksEnabled: @escaping () -> Bool, - cancelsEffectsOnDeinit: @escaping () -> Bool + @inlinable + init( + rootStore: Store, + state toScopedState: @escaping (RootState) -> ScopedState, + action fromScopedAction: @escaping (ScopedAction) -> RootAction, + parentStores: [Any] ) { - self.useNewScope = useNewScope - self.mainThreadChecksEnabled = mainThreadChecksEnabled - self.cancelsEffectsOnDeinit = cancelsEffectsOnDeinit + self.rootStore = rootStore + self.toScopedState = toScopedState + self.fromScopedAction = fromScopedAction + self.parentStores = parentStores + } + + @inlinable + func reduce( + into state: inout ScopedState, action: ScopedAction + ) -> Effect { + self.isSending = true + defer { + state = self.toScopedState(self.rootStore.state) + self.isSending = false + } + if let task = self.rootStore.send(self.fromScopedAction(action)) { + return .fireAndForget { await task.cancellableValue } + } else { + return .none + } } } -extension StoreConfig { - public static var `default`: StoreConfig = .init( - useNewScope: { true }, - mainThreadChecksEnabled: { true }, - cancelsEffectsOnDeinit: { true } - ) +protocol AnyScopedReducer { + func rescope( + _ store: Store, + state toRescopedState: @escaping (ScopedState) -> RescopedState, + action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction + ) -> Store +} + +extension ScopedReducer: AnyScopedReducer { + @inlinable + func rescope( + _ store: Store, + state toRescopedState: @escaping (ScopedState) -> RescopedState, + action fromRescopedAction: @escaping (RescopedAction) -> ScopedAction + ) -> Store { + let fromScopedAction = self.fromScopedAction as! (ScopedAction) -> RootAction + let reducer = ScopedReducer( + rootStore: self.rootStore, + state: { _ in toRescopedState(store.state) }, + action: { fromScopedAction(fromRescopedAction($0)) }, + parentStores: self.parentStores + [store] + ) + let childStore = Store( + initialState: toRescopedState(store.state), + reducer: reducer, + useNewScope: self.rootStore.useNewScope + ) + store.relay + .skip(1) + .subscribe(onNext: { [weak childStore] newValue in + guard !reducer.isSending else { return } + childStore?.relay.accept(toRescopedState(newValue)) + }) + .disposed(by: store.disposeBag) + return childStore + } +} + +extension Store where State: Collection, State.Element: HashDiffable, State: Equatable, State.Element: Equatable { + /** + A version of scope that scope an collection of sub store. + + This is kinda a version of `ForEachStoreNode`, not composing `WithViewStore` but creates the sub store. + + ## Example + ``` + struct AppState { var todos: [Todo] } + struct AppAction { case todo(index: Int, action: TodoAction } + + store.subscribe(\.todos) + .drive(onNext: { todos in + self.todoNodes = zip(todos.indices, todos).map { (offset, _) in + TodoNode(with: store.scope( + identifier: identifier, + action: Action.todo(index:action:) + ) + } + }) + .disposed(by: disposeBag) + ``` + + But with example above, you created the entire node again and again and it's not the efficient way. + You can do some diffing and only creating spesific index, and rest is handle by diffing. + + - Parameters: + - identifier: the identifier from `IdentifierType` make sure index is in bounds of the collection + - action: A function to transform `LocalAction` to `Action`. `LocalAction` should have `(CollectionIndex, LocalAction)` signature. + + - Returns: A new store with its domain (state and domain) transformed based on the index you set + */ + public func scope( + at identifier: State.Element.IdentifierType, + action fromLocalAction: @escaping (LocalAction) -> Action + ) -> Store? { + self.threadCheck(status: .scope) + let toLocalState: (State.Element.IdentifierType, State) -> State.Element? = { identifier, state in + /** + if current state is IdentifiedArray, use pre exist subscript by identifier, to improve performance + */ + if let identifiedArray = state as? IdentifiedArrayOf { + return identifiedArray[id: identifier] + } else { + return state.first(where: { $0.id == identifier }) + } + } + + var isSending = false + // skip if element on parent state wasn't found + guard let element = toLocalState(identifier, state) else { return nil } + + let localStore = Store.init( + initialState: element, + reducer: Reduce(internal: { localState, localAction in + isSending = true + defer { isSending = false } + if let task = self.send(fromLocalAction(localAction)) { + return .fireAndForget { + await task.cancellableValue + } + } else { + guard let finalState = toLocalState(identifier, self.state) else { + return .none + } + localState = finalState + return .none + } + }), + useNewScope: useNewScope + ) + + relay + .skip(1) + .subscribe(onNext: { [weak localStore] newValue in + guard !isSending else { return } + guard let element = toLocalState(identifier, newValue) else { return } + localStore?.state = element + }) + .disposed(by: localStore.disposeBag) + + return localStore + } } +#endif diff --git a/Sources/RxComposableArchitecture/StoreConfig.swift b/Sources/RxComposableArchitecture/StoreConfig.swift new file mode 100644 index 0000000..d5b5401 --- /dev/null +++ b/Sources/RxComposableArchitecture/StoreConfig.swift @@ -0,0 +1,23 @@ +public struct StoreConfig { + public var useNewScope: () -> Bool + public var mainThreadChecksEnabled: () -> Bool + public var cancelsEffectsOnDeinit: () -> Bool + + public init( + useNewScope: @escaping () -> Bool, + mainThreadChecksEnabled: @escaping () -> Bool, + cancelsEffectsOnDeinit: @escaping () -> Bool + ) { + self.useNewScope = useNewScope + self.mainThreadChecksEnabled = mainThreadChecksEnabled + self.cancelsEffectsOnDeinit = cancelsEffectsOnDeinit + } +} + +extension StoreConfig { + public static var `default`: StoreConfig = .init( + useNewScope: { true }, + mainThreadChecksEnabled: { true }, + cancelsEffectsOnDeinit: { true } + ) +} diff --git a/Sources/RxComposableArchitecture/TestSupport/Effect+Failing.swift b/Sources/RxComposableArchitecture/TestSupport/Effect+Failing.swift index 63ab921..bbe26a5 100644 --- a/Sources/RxComposableArchitecture/TestSupport/Effect+Failing.swift +++ b/Sources/RxComposableArchitecture/TestSupport/Effect+Failing.swift @@ -6,6 +6,7 @@ // #if DEBUG +import XCTestDynamicOverlay extension Effect { /// An effect that causes a test to fail if it runs. /// @@ -79,7 +80,7 @@ extension 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 failing(_ prefix: String) -> Effect { + public static func failing(_ prefix: String) -> Effect { .fireAndForget { XCTFail("\(prefix.isEmpty ? "" : "\(prefix) - ")A failing effect ran.") } diff --git a/Sources/RxComposableArchitecture/TestSupport/TestStore.swift b/Sources/RxComposableArchitecture/TestSupport/TestStore.swift index 253f6a6..7675fe8 100644 --- a/Sources/RxComposableArchitecture/TestSupport/TestStore.swift +++ b/Sources/RxComposableArchitecture/TestSupport/TestStore.swift @@ -1,6 +1,7 @@ #if DEBUG import Foundation import RxSwift +import XCTestDynamicOverlay /// A testable runtime for a reducer. /// @@ -9,8 +10,13 @@ import RxSwift /// 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: +/// See the dedicated article for detailed information on testing. +/// +/// ## Exhaustive testing +/// +/// By default, ``TestStore`` requires you to exhaustively prove how your feature evolves from +/// sending use actions and receiving actions from effects. There are multiple ways the test store +/// forces you to do this: /// /// * After each action is sent you must describe precisely how the state changed from before /// the action was sent to after it was sent. @@ -18,744 +24,2212 @@ import RxSwift /// 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. +/// * Sending an action can sometimes cause an effect to be executed, and if that effect sends +/// an action 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 +/// If you try to send another action before you have handled all effect actions, the +/// test will fail. This guarantees that you do not accidentally forget about an effect +/// action, 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. +/// * All effects must complete by the time the test case has finished running, and all effect +/// actions must be asserted on. /// -/// 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. +/// If at the end of the assertion there is still an in-flight effect running or an unreceived +/// action, 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: /// -/// struct CounterState { -/// var count = 0 -/// } +/// ```swift +/// struct Counter: ReducerProtocol { +/// struct State: Equatable { +/// var count = 0 +/// } /// -/// enum CounterAction: Equatable { -/// case decrementButtonTapped -/// case incrementButtonTapped -/// } +/// enum Action { +/// case decrementButtonTapped +/// case incrementButtonTapped +/// } /// -/// let counterReducer = Reducer { state, action, _ in -/// switch action { -/// case .decrementButtonTapped: -/// state.count -= 1 -/// return .none +/// func reduce( +/// into state: inout State, action: Action +/// ) -> EffectTask { +/// switch action { +/// case .decrementButtonTapped: +/// state.count -= 1 +/// return .none /// -/// case .incrementButtonTapped: -/// state.count += 1 -/// return .none -/// } +/// case .incrementButtonTapped: +/// state.count += 1 +/// return .none /// } +/// } +/// } +/// ``` /// /// One can assert against its behavior over time: /// -/// class CounterTests: XCTestCase { -/// func testCounter() { -/// let store = TestStore( -/// initialState: .init(count: 0), // GIVEN 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 -/// } -/// } +/// ```swift +/// @MainActor +/// class CounterTests: XCTestCase { +/// func testCounter() async { +/// let store = TestStore( +/// // Given: a counter state of 0 +/// initialState: Counter.State(count: 0), +/// reducer: Counter() +/// ) +/// +/// // When: the increment button is tapped +/// await store.send(.incrementButtonTapped) { +/// // Then: the count should be 1 +/// $0.count = 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 -/// `.debounce` operator to wait for the user to stop typing before making a network request: +/// If the change made in the closure does not reflect reality, you will get a test failure with +/// a nicely formatted failure message letting you know exactly what went wrong: /// -/// struct SearchState: Equatable { -/// var query = "" -/// var results: [String] = [] -/// } +/// ```swift +/// await store.send(.incrementButtonTapped) { +/// $0.count = 42 +/// } +/// ``` /// -/// enum SearchAction: Equatable { -/// case queryChanged(String) -/// case response([String]) -/// } +/// ``` +/// 🛑 A state change does not match expectation: … /// -/// struct SearchEnvironment { -/// var mainQueue: AnySchedulerOf -/// var request: (String) -> Effect<[String], Never> -/// } +/// TestStoreFailureTests.State( +/// − count: 42 +/// + count: 1 +/// ) /// -/// let searchReducer = Reducer { -/// state, action, environment in +/// (Expected: −, Actual: +) +/// ``` /// -/// enum SearchId {} +/// For a more complex example, consider the following bare-bones search feature that uses a +/// clock and cancel token to debounce requests: /// -/// switch action { -/// case let .queryChanged(query): -/// state.query = query -/// return environment.request(self.query) -/// .debounce(id: SearchId.self, for: 0.5, scheduler: environment.mainQueue) +/// ```swift +/// struct Search: ReducerProtocol { +/// struct State: Equatable { +/// var query = "" +/// var results: [String] = [] +/// } /// -/// case let .response(results): -/// state.results = results -/// return .none -/// } -/// } +/// enum Action: Equatable { +/// case queryChanged(String) +/// case searchResponse(TaskResult<[String]>) +/// } /// -/// It can be fully tested by controlling the environment's scheduler and effect: +/// @Dependency(\.apiClient) var apiClient +/// @Dependency(\.continuousClock) var clock +/// private enum SearchID {} /// -/// // Create a test dispatch scheduler to control the timing of effects -/// let scheduler = DispatchQueue.testScheduler +/// func reduce( +/// into state: inout State, action: Action +/// ) -> EffectTask { +/// switch action { +/// case let .queryChanged(query): +/// state.query = query +/// return .run { send in +/// try await self.clock.sleep(for: 0.5) /// -/// let store = TestStore( -/// initialState: SearchState(), -/// reducer: searchReducer, -/// environment: SearchEnvironment( -/// // Wrap the test scheduler in a type-erased scheduler -/// mainQueue: scheduler.eraseToAnyScheduler(), -/// // Simulate a search response with one item -/// request: { _ in Effect(value: ["Composable Architecture"]) } -/// ) -/// ) +/// guard let results = try? await self.apiClient.search(query) +/// else { return } /// -/// // Change the query -/// store.send(.searchFieldChanged("c") { -/// // Assert that state updates accordingly -/// $0.query = "c" -/// } +/// await send(.response(results)) +/// } +/// .cancellable(id: SearchID.self) /// -/// // Advance the scheduler by a period shorter than the debounce -/// scheduler.advance(by: 0.25) +/// case let .searchResponse(.success(results)): +/// state.results = results +/// return .none /// -/// // Change the query again -/// store.send(.searchFieldChanged("co") { -/// $0.query = "co" +/// case .searchResponse(.failure): +/// // Do error handling here. +/// return .none /// } +/// } +/// } +/// ``` /// -/// // Advance the scheduler by a period shorter than the debounce -/// scheduler.advance(by: 0.25) -/// // Advance the scheduler to the debounce -/// scheduler.advance(by: 0.25) +/// It can be fully tested by overriding the `apiClient` and `continuousClock` dependencies with +/// values that are fully controlled and deterministic: /// -/// // Assert that the expected response is received -/// store.receive(.response(["Composable Architecture"])) { -/// // Assert that state updates accordingly -/// $0.results = ["Composable Architecture"] -/// } +/// ```swift +/// let store = TestStore( +/// initialState: Search.State(), +/// reducer: Search() +/// ) +/// +/// // Simulate a search response with one item +/// store.dependencies.apiClient.search = { _ in +/// ["Composable Architecture"] +/// } +/// +/// // Create a test clock to control the timing of effects +/// let clock = TestClock() +/// store.dependencies.continuousClock = clock +/// +/// // Change the query +/// await store.send(.searchFieldChanged("c") { +/// // Assert that state updates accordingly +/// $0.query = "c" +/// } +/// +/// // Advance the clock by enough to get past the debounce +/// await clock.advance(by: 0.5) +/// +/// // Assert that the expected response is received +/// await store.receive(.searchResponse(.success(["Composable Architecture"]))) { +/// $0.results = ["Composable Architecture"] +/// } +/// ``` +/// +/// This test is proving that when the search query changes some search responses are delivered and +/// state updates accordingly. +/// +/// If we did not assert that the `searchResponse` action was received, we would get the following +/// test failure: +/// +/// ``` +/// 🛑 The store received 1 unexpected action after this one: … +/// +/// Unhandled actions: [ +/// [0]: Search.Action.searchResponse +/// ] +/// ``` +/// +/// This helpfully lets us know that we have no asserted on everything that happened in the feature, +/// which could be hiding a bug from us. +/// +/// Or if we had sent another action before handling the effect's action we would have also gotten +/// a test failure: +/// +/// ``` +/// 🛑 Must handle 1 received action before sending an action: … +/// +/// Unhandled actions: [ +/// [0]: Search.Action.searchResponse +/// ] +/// ``` +/// +/// All of these types of failures help you prove that you know exactly how your feature evolves +/// as actions are sent into the system. If the library did not produce a test failure in these +/// situations it could be hiding subtle bugs in your code. For example, when the user clears the +/// search query you probably expect that the results are cleared and no search request is executed +/// since there is no query. This can be done like so: +/// +/// ```swift +/// await store.send(.queryChanged("")) { +/// $0.query = "" +/// $0.results = [] +/// } +/// +/// // No need to perform `store.receive` since we do not expect a search +/// // effect to execute. +/// ``` +/// +/// But, if in the future a bug is introduced causing a search request to be executed even when the +/// query is empty, you will get a test failure because a new effect is being created that is +/// not being asserted on. This is the power of exhaustive testing. +/// +/// ## Non-exhaustive testing +/// +/// While exhaustive testing can be powerful, it can also be a nuisance, especially when testing +/// how many features integrate together. This is why sometimes you may want to selectively test +/// in a non-exhaustive style. +/// +/// > Tip: The concept of "non-exhaustive test store" was first introduced by +/// [Krzysztof Zabłocki][merowing.info] in a [blog post][exhaustive-testing-in-tca] and +/// [conference talk][Composable-Architecture-at-Scale], and then later became integrated into the +/// core library. +/// +/// Test stores are exhaustive by default, which means you must assert on every state change, and +/// how ever effect feeds data back into the system, and you must make sure that all effects +/// complete before the test is finished. To turn off exhaustivity you can set ``exhaustivity`` +/// to ``Exhaustivity/off``. When that is done the ``TestStore``'s behavior changes: +/// +/// * The trailing closures of ``send(_:assert:file:line:)-1ax61`` and +/// ``receive(_:timeout:assert:file:line:)-1rwdd`` no longer need to assert on all state changes. +/// They can assert on any subset of changes, and only if they make an incorrect mutation will a +/// test failure be reported. +/// * The ``send(_:assert:file:line:)-1ax61`` and ``receive(_:timeout:assert:file:line:)-1rwdd`` +/// methods are allowed to be called even when actions have been received from effects that have +/// not been asserted on yet. Any pending actions will be cleared. +/// * Tests are allowed to finish with unasserted, received actions and in-flight effects. No test +/// failures will be reported. +/// +/// Non-exhaustive stores can be configured to report skipped assertions by configuring +/// ``Exhaustivity/off(showSkippedAssertions:)``. When set to `true` the test store will have the +/// added behavior that any unasserted change causes a grey, informational box to appear next to +/// each assertion detailing the changes that were not asserted against. This allows you to see what +/// information you are choosing to ignore without causing a test failure. It can be useful in +/// tracking down bugs that happen in production but that aren't currently detected in tests. +/// +/// This style of testing is most useful for testing the integration of multiple features where you +/// want to focus on just a certain slice of the behavior. Exhaustive testing can still be important +/// to use for leaf node features, where you truly do want to assert on everything happening inside +/// the feature. +/// +/// For example, suppose you have a tab-based application where the 3rd tab is a login screen. The +/// user can fill in some data on the screen, then tap the "Submit" button, and then a series of +/// events happens to log the user in. Once the user is logged in, the 3rd tab switches from a +/// login screen to a profile screen, _and_ the selected tab switches to the first tab, which is an +/// activity screen. +/// +/// When writing tests for the login feature we will want to do that in the exhaustive style so that +/// we can prove exactly how the feature would behave in production. But, suppose we wanted to write +/// an integration test that proves after the user taps the "Login" button that ultimately the +/// selected tab switches to the first tab. +/// +/// In order to test such a complex flow we must test the integration of multiple features, which +/// means dealing with complex, nested state and effects. We can emulate this flow in a test by +/// sending actions that mimic the user logging in, and then eventually assert that the selected +/// tab switched to activity: +/// +/// ```swift +/// let store = TestStore( +/// initialState: App.State(), +/// reducer: App() +/// ) +/// +/// // 1️⃣ Emulate user tapping on submit button. +/// await store.send(.login(.submitButtonTapped)) { +/// // 2️⃣ Assert how all state changes in the login feature +/// $0.login?.isLoading = true +/// … +/// } +/// +/// // 3️⃣ Login feature performs API request to login, and +/// // sends response back into system. +/// await store.receive(.login(.loginResponse(.success))) { +/// // 4️⃣ Assert how all state changes in the login feature +/// $0.login?.isLoading = false +/// … +/// } +/// +/// // 5️⃣ Login feature sends a delegate action to let parent +/// // feature know it has successfully logged in. +/// await store.receive(.login(.delegate(.didLogin))) { +/// // 6️⃣ Assert how all of app state changes due to that action. +/// $0.authenticatedTab = .loggedIn( +/// Profile.State(...) +/// ) +/// … +/// // 7️⃣ *Finally* assert that the selected tab switches to activity. +/// $0.selectedTab = .activity +/// } +/// ``` +/// +/// Doing this with exhaustive testing is verbose, and there are a few problems with this: +/// +/// * We need to be intimately knowledgeable in how the login feature works so that we can assert +/// on how its state changes and how its effects feed data back into the system. +/// * If the login feature were to change its logic we may get test failures here even though the +/// logic we are actually trying to test doesn't really care about those changes. +/// * This test is very long, and so if there are other similar but slightly different flows we +/// want to test we will be tempted to copy-and-paste the whole thing, leading to lots of +/// duplicated, fragile tests. +/// +/// Non-exhaustive testing allows us to test the high-level flow that we are concerned with, that of +/// login causing the selected tab to switch to activity, without having to worry about what is +/// happening inside the login feature. To do this, we can turn off ``TestStore/exhaustivity`` in +/// the test store, and then just assert on what we are interested in: +/// +/// ```swift +/// let store = TestStore( +/// initialState: App.State(), +/// reducer: App() +/// ) +/// store.exhaustivity = .off // ⬅️ +/// +/// await store.send(.login(.submitButtonTapped)) +/// await store.receive(.login(.delegate(.didLogin))) { +/// $0.selectedTab = .activity +/// } +/// ``` +/// +/// In particular, we did not assert on how the login's state changed or how the login's effects fed +/// data back into the system. We just assert that when the "Submit" button is tapped that +/// eventually we get the `didLogin` delegate action and that causes the selected tab to flip to +/// activity. Now the login feature is free to make any change it wants to make without affecting +/// this integration test. +/// +/// Using ``Exhaustivity/off`` for ``TestStore/exhaustivity`` causes all un-asserted changes to +/// pass without any notification. If you would like to see what test failures are being suppressed +/// without actually causing a failure, you can use ``Exhaustivity/off(showSkippedAssertions:)``: +/// +/// ```swift +/// let store = TestStore( +/// initialState: App.State(), +/// reducer: App() +/// ) +/// store.exhaustivity = .off(showSkippedAssertions: true) // ⬅️ +/// +/// await store.send(.login(.submitButtonTapped)) +/// await store.receive(.login(.delegate(.didLogin))) { +/// $0.selectedTab = .profile +/// } +/// ``` +/// +/// When this is run you will get grey, informational boxes on each assertion where some change +/// wasn't fully asserted on: +/// +/// ``` +/// ◽️ A state change does not match expectation: … +/// +///   App.State( +///   authenticatedTab: .loggedOut( +/// Login.State( +/// − isLoading: false +/// + isLoading: true, +/// … +/// ) +/// ) +///   ) +/// +/// (Expected: −, Actual: +) +/// +/// ◽️ Skipped receiving .login(.loginResponse(.success)) +/// +/// ◽️ A state change does not match expectation: … /// -/// 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. +///   App.State( +/// − authenticatedTab: .loggedOut(…) +/// + authenticatedTab: .loggedIn( +/// + Profile.State(…) +/// + ), +/// … +///   ) /// -public final class TestStore { +/// (Expected: −, Actual: +) +/// ``` +/// +/// The test still passes, and none of these notifications are test failures. They just let you know +/// what things you are not explicitly asserting against, and can be useful to see when tracking +/// down bugs that happen in production but that aren't currently detected in tests. +/// +/// [merowing.info]: https://www.merowing.info +/// [exhaustive-testing-in-tca]: https://www.merowing.info/exhaustive-testing-in-tca/ +/// [Composable-Architecture-at-Scale]: https://vimeo.com/751173570 +public final class TestStore { + + /// The current dependencies of the test store. + /// + /// The dependencies define the execution context that your feature runs in. They can be + /// modified throughout the test store's lifecycle in order to influence how your feature + /// produces effects. + /// + /// Typically you will override certain dependencies immediately after constructing the test + /// store. For example, if your feature need access to the current date and an API client to + /// do its job, you can override those dependencies like so: + /// + /// ```swift + /// let store = TestStore(/* ... */) + /// + /// store.dependencies.apiClient = .mock + /// store.dependencies.date = .constant(Date(timeIntervalSinceReferenceDate: 1234567890)) + /// + /// // Store assertions here + /// ``` + /// + /// You can also override dependencies in the middle of the test in order to simulate how the + /// dependency changes as the user performs action. For example, to test the flow of an API + /// request failing at first but then later succeeding, you can do the following: + /// + /// ```swift + /// store.dependencies.apiClient = .failing + /// + /// store.send(.buttonTapped) { /* ... */ } + /// store.receive(.searchResponse(.failure)) { /* ... */ } + /// + /// store.dependencies.apiClient = .mock + /// + /// store.send(.buttonTapped) { /* ... */ } + /// store.receive(.searchResponse(.success)) { /* ... */ } + /// ``` + public var dependencies: DependencyValues { + _read { yield self.reducer.dependencies } + _modify { yield &self.reducer.dependencies } + } + + /// The current exhaustivity level of the test store. + public var exhaustivity: Exhaustivity = .on /// 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 + /// 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!"))) { + /// … + /// } + /// ``` + @available( + iOS, + deprecated: 9999, + message: + """ + 'Reducer' and `Environment` have been deprecated in favor of 'ReducerProtocol' and 'DependencyValues'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + @available( + macOS, + deprecated: 9999, + message: + """ + 'Reducer' and `Environment` have been deprecated in favor of 'ReducerProtocol' and 'DependencyValues'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + @available( + tvOS, + deprecated: 9999, + message: + """ + 'Reducer' and `Environment` have been deprecated in favor of 'ReducerProtocol' and 'DependencyValues'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + @available( + watchOS, + deprecated: 9999, + message: + """ + 'Reducer' and `Environment` have been deprecated in favor of 'ReducerProtocol' and 'DependencyValues'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public var environment: Environment { + _read { yield self._environment.wrappedValue } + _modify { yield &self._environment.wrappedValue } + } - /// The current state. + /// The current state of the test store. /// /// 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 + /// ``receive(_:_:file:line:)``, it will equal the `inout` state passed to the + /// closure. + public var state: State { + self.reducer.state + } + + /// The default timeout used in all methods that take an optional timeout. + /// + /// This is the default timeout used in all methods that take an optional timeout, such as + /// ``receive(_:timeout:assert:file:line:)-332q2`` and ``finish(timeout:file:line:)-7pmv3``. + public var timeout: UInt64 + private var _environment: TaskBox private let file: StaticString - private let fromLocalAction: (LocalAction) -> Action + private let fromScopedAction: (ScopedAction) -> Action private var line: UInt - private var inFlightEffects: Set = [] - private var receivedActions: [(action: Action, state: State)] = [] - private let reducer: Reducer - private var store: Store! - private let toLocalState: (State) -> LocalState + internal let reducer: TestReducer + private let store: Store.TestAction> + private let toScopedState: (State) -> ScopedState private let failingWhenNothingChange: Bool private let useNewScope: Bool - - public var stateDiffMode: DiffMode = .distinct - public var actionDiffMode: DiffMode = .distinct - private init( + /// Creates a test store with an initial state and a reducer powering it's runtime. + /// + /// See and the documentation of ``TestStore`` for more information on how to best + /// use a test store. + /// + /// - Parameters: + /// - initialState: The state the feature starts in. + /// - reducer: The reducer that powers the runtime of the feature. + /// - failingWhenNothingChange: Flag to make test failed if you provide trailing closure on ``send(_:_:file:line:)`` or ``receive(_:_:file:line:)`` but the state is the same + /// - useNewScope: Increase performance + public init( + initialState: @autoclosure () -> State, + reducer: Reducer, + prepareDependencies: (inout DependencyValues) -> Void = { _ in }, + file: StaticString = #file, + line: UInt = #line, + failingWhenNothingChange: Bool = true, + useNewScope: Bool = false + ) + where + Reducer.State == State, + Reducer.Action == Action, + State == ScopedState, + Action == ScopedAction, + Environment == Void + { + var dependencies = DependencyValues() + dependencies.context = .test + prepareDependencies(&dependencies) + + let initialState = DependencyValues.$_current.withValue(dependencies) { initialState() } + + let reducer = TestReducer(Reduce(reducer), initialState: initialState) + self._environment = .init(wrappedValue: ()) + self.file = file + self.fromScopedAction = { $0 } + self.line = line + self.reducer = reducer + self.timeout = 100 * NSEC_PER_MSEC + self.toScopedState = { $0 } + self.failingWhenNothingChange = failingWhenNothingChange + self.useNewScope = useNewScope + self.store = Store( + initialState: initialState, + reducer: reducer, + useNewScope: useNewScope + ) + self.dependencies = dependencies + } + + @available( + iOS, + deprecated: 9999, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + @available( + macOS, + deprecated: 9999, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + @available( + tvOS, + deprecated: 9999, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + @available( + watchOS, + deprecated: 9999, + message: + """ + 'Reducer' has been deprecated in favor of 'ReducerProtocol'. + + See the migration guide for more information: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/reducerprotocol + """ + ) + public init( + initialState: ScopedState, + reducer: AnyReducer, environment: Environment, - file: StaticString, - fromLocalAction: @escaping (LocalAction) -> Action, - initialState: State, - line: UInt, - reducer: Reducer, - toLocalState: @escaping (State) -> LocalState, - failingWhenNothingChange: Bool, - useNewScope: Bool - ) { - self.environment = environment + file: StaticString = #file, + line: UInt = #line, + failingWhenNothingChange: Bool = true, + useNewScope: Bool = false + ) + where State == ScopedState, Action == ScopedAction { + let environment = TaskBox(wrappedValue: environment) + let reducer = TestReducer( + Reduce( + reducer.pullback(state: \.self, action: .self, environment: { $0.wrappedValue }), + environment: environment + ), + initialState: initialState + ) + self._environment = environment self.file = file - self.fromLocalAction = fromLocalAction + self.fromScopedAction = { $0 } self.line = line self.reducer = reducer - state = initialState - self.toLocalState = toLocalState + self.timeout = 100 * NSEC_PER_MSEC + self.toScopedState = { $0 } self.failingWhenNothingChange = failingWhenNothingChange self.useNewScope = useNewScope - store = Store( + 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 - .do( - onCompleted: { [weak self] in self?.inFlightEffects.remove(effect) }, - onSubscribe: { [weak self] in self?.inFlightEffects.insert(effect) }, - onDispose: { [weak self] in self?.inFlightEffects.remove(effect) } - ) - .map { .init(origin: .receive($0), file: action.file, line: action.line) } - .eraseToEffect() - }, - environment: (), + reducer: reducer, useNewScope: useNewScope ) } - deinit { - self.completed() + internal init( + _environment: TaskBox, + file: StaticString, + fromScopedAction: @escaping (ScopedAction) -> Action, + line: UInt, + reducer: TestReducer, + store: Store.Action>, + timeout: UInt64 = 100 * NSEC_PER_MSEC, + toScopedState: @escaping (State) -> ScopedState, + failingWhenNothingChange: Bool = true, + useNewScope: Bool = false + ) { + self._environment = _environment + self.file = file + self.fromScopedAction = fromScopedAction + self.line = line + self.reducer = reducer + self.store = store + self.timeout = timeout + self.toScopedState = toScopedState + self.failingWhenNothingChange = failingWhenNothingChange + self.useNewScope = useNewScope } - private func completed() { - if !receivedActions.isEmpty { - var actions = "" - customDump(self.receivedActions.map(\.action), to: &actions) - XCTFail( - """ - The store received \(receivedActions.count) unexpected \ - action\(receivedActions.count == 1 ? "" : "s") after this one: … - - Unhandled actions: \(actions) - """, - file: file, line: line - ) + // NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. + // See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 + #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) + /// 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) } - for effect in inFlightEffects { - XCTFail( + #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. + @_disfavoredOverload + @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.reducer.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 \ + clock/scheduler (via "receive(on:)", "delay", "debounce", etc.), make sure that you wait \ + enough time for it to perform the effect. If you are using a test \ + clock/scheduler, advance it so that the effects may complete, or consider using \ + an immediate clock/scheduler to immediately perform the effect instead. + If you are not yet using a clock/scheduler, or can not use a clock/scheduler, \ + \(timeoutMessage). """ - 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 - ) + XCTFailHelper( + """ + 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() } } - 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) { - id.hash(into: &hasher) - } + deinit { + self.completed() } - private struct TestAction: CustomDebugStringConvertible { - let origin: Origin - let file: StaticString - let line: UInt - - enum Origin { - case send(LocalAction) - case receive(Action) + internal func completed() { + if !self.reducer.receivedActions.isEmpty { + var actions = "" + customDump(self.reducer.receivedActions.map(\.action), to: &actions) + XCTFailHelper( + """ + The store received \(self.reducer.receivedActions.count) unexpected \ + action\(self.reducer.receivedActions.count == 1 ? "" : "s") after this one: … + + Unhandled actions: \(actions) + """, + file: self.file, + line: self.line + ) } - - var debugDescription: String { - switch self.origin { - case let .send(action): - return debugCaseOutput(action) - - case let .receive(action): - return debugCaseOutput(action) - } + for effect in self.reducer.inFlightEffects { + XCTFailHelper( + """ + 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 clock/scheduler (via "receive(on:)", "delay", "debounce", etc.), \ + make sure that you wait enough time for it to perform the effect. If you are using \ + a test clock/scheduler, advance it so that the effects may complete, or consider \ + using an immediate clock/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.action.file, + line: effect.action.line + ) } } } -extension TestStore where State == LocalState, Action == LocalAction { - /// Initializes a test store from an initial state, a reducer, and an initial environment. +extension TestStore where ScopedState: Equatable { + /// Sends an action to the store and asserts when state changes. + /// + /// To assert on how state changes you can provide a trailing closure, and that closure is handed + /// a mutable variable that represents the feature's state _before_ the action was sent. You need + /// to mutate that variable so that it is equal to the feature's state _after_ the action is sent: + /// + /// ```swift + /// await store.send(.incrementButtonTapped) { + /// $0.count = 1 + /// } + /// await store.send(.decrementButtonTapped) { + /// $0.count = 0 + /// } + /// ``` + /// + /// This method suspends in order to allow any effects to start. For example, if you + /// track an analytics event in a ``EffectPublisher/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: - /// - initialState: The state to start the test from. - /// - reducer: A reducer. - /// - environment: The environment to start the test from. - /// - failingWhenNothingChange: Flag to failing the test if the trailing closure on send and receive is provided but nothing is changed - /// - useNewScope: Use improved store. - public convenience init( - initialState: State, - reducer: Reducer, - environment: Environment, - failingWhenNothingChange: Bool = true, - useNewScope: Bool = false, - file: StaticString = #file, - line: UInt = #line - ) { - self.init( - environment: environment, - file: file, - fromLocalAction: { $0 }, - initialState: initialState, - line: line, - reducer: reducer, - toLocalState: { $0 }, - failingWhenNothingChange: failingWhenNothingChange, - useNewScope: useNewScope - ) - } -} - -extension TestStore where LocalState: Equatable { + /// - action: An action. + /// - updateStateToExpectedResult: 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, + _ action: ScopedAction, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line - ) { - if !receivedActions.isEmpty { + ) async -> TestStoreTask { + if !self.reducer.receivedActions.isEmpty { var actions = "" - customDump(self.receivedActions.map(\.action), to: &actions) - XCTFail( + customDump(self.reducer.receivedActions.map(\.action), to: &actions) + XCTFailHelper( """ - Must handle \(receivedActions.count) received \ - action\(receivedActions.count == 1 ? "" : "s") before sending an action: … - - Unhandled actions: \(action) + Must handle \(self.reducer.receivedActions.count) received \ + action\(self.reducer.receivedActions.count == 1 ? "" : "s") before sending an action: … + Unhandled actions: \(actions) """, - file: file, line: line + file: file, + line: line ) } - var expectedState = toLocalState(self.state) - let previousState = self.state - self.store.send(.init(origin: .send(action), file: file, line: line)) + + switch self.exhaustivity { + case .on: + break + case .off(showSkippedAssertions: true): + await self.skipReceivedActions(strict: false) + case .off(showSkippedAssertions: false): + self.reducer.receivedActions = [] + } + + let expectedState = self.toScopedState(self.state) + let previousState = self.reducer.state + let task = self.store + .send(.init(origin: .send(self.fromScopedAction(action)), file: file, line: line)) + await self.reducer.effectDidSubscribe.stream.first(where: { _ in true }) do { let currentState = self.state - self.state = previousState - defer { self.state = currentState } - try expectedStateShouldMatch( - expected: &expectedState, - actual: toLocalState(currentState), - modify: updateExpectingResult, + self.reducer.state = previousState + defer { self.reducer.state = currentState } + + try self.expectedStateShouldMatch( + expected: expectedState, + actual: self.toScopedState(currentState), + updateStateToExpectedResult: updateStateToExpectedResult, file: file, line: line ) } catch { XCTFail("Threw error: \(error)", file: file, line: line) } - if "\(self.file)" == "\(file)" { self.line = line } + // NB: Give concurrency runtime more time to kick off effects so users don't need to manually + // instrument their effects. + await Task.megaYield(count: 20) + 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 && failingWhenNothingChange { - 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. + /// 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: - /// - expectedAction: An action expected from an effect. + /// - 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 receive( - _ expectedAction: Action, - _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, + /// - Returns: A ``TestStoreTask`` that represents the lifecycle of the effect executed when + /// sending the action. + @available(iOS, deprecated: 9999, message: "Call the async-friendly 'send' instead.") + @available(macOS, deprecated: 9999, message: "Call the async-friendly 'send' instead.") + @available(tvOS, deprecated: 9999, message: "Call the async-friendly 'send' instead.") + @available(watchOS, deprecated: 9999, message: "Call the async-friendly 'send' instead.") + @discardableResult + public func send( + _ action: ScopedAction, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line - ) { - guard !receivedActions.isEmpty else { - XCTFail( - """ - Expected to receive an action, but received none. - """, - file: file, line: line - ) - return - } - let (receivedAction, state) = 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)) + ) -> TestStoreTask { + if !self.reducer.receivedActions.isEmpty { + var actions = "" + customDump(self.reducer.receivedActions.map(\.action), to: &actions) + XCTFailHelper( """ + Must handle \(self.reducer.receivedActions.count) received \ + action\(self.reducer.receivedActions.count == 1 ? "" : "s") before sending an action: … - XCTFail( - """ - Received unexpected action: … - - \(difference) - """, - file: file, line: line + Unhandled actions: \(actions) + """, + file: file, + line: line ) } - var expectedState = toLocalState(self.state) + + switch self.exhaustivity { + case .on: + break + case .off(showSkippedAssertions: true): + self.skipReceivedActions(strict: false) + case .off(showSkippedAssertions: false): + self.reducer.receivedActions = [] + } + + let expectedState = self.toScopedState(self.state) + let previousState = self.state + let task = self.store + .send(.init(origin: .send(self.fromScopedAction(action)), file: file, line: line)) do { - try expectedStateShouldMatch( - expected: &expectedState, - actual: toLocalState(state), - modify: updateExpectingResult, + let currentState = self.state + self.reducer.state = previousState + defer { self.reducer.state = currentState } + + try self.expectedStateShouldMatch( + expected: expectedState, + actual: self.toScopedState(currentState), + updateStateToExpectedResult: updateStateToExpectedResult, file: file, line: line ) } catch { XCTFail("Threw error: \(error)", file: file, line: line) } - - self.state = state if "\(self.file)" == "\(file)" { self.line = line } + + return .init(rawValue: task, timeout: self.timeout) } - /// Asserts against a script of actions. - public func assert( - _ steps: Step..., - groupLevel: Int = 0, + private func expectedStateShouldMatch( + expected: ScopedState, + actual: ScopedState, + updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString, + line: UInt + ) throws { + let current = expected + var expected = expected + + switch self.exhaustivity { + case .on: + var expectedWhenGivenPreviousState = expected + if let updateStateToExpectedResult = updateStateToExpectedResult { + try DependencyValues.$_current.withValue(self.dependencies) { + try updateStateToExpectedResult(&expectedWhenGivenPreviousState) + } + } + expected = expectedWhenGivenPreviousState + + if expectedWhenGivenPreviousState != actual { + expectationFailure(expected: expectedWhenGivenPreviousState) + } else { + tryUnnecessaryModifyFailure() + } + + case .off: + var expectedWhenGivenActualState = actual + if let updateStateToExpectedResult = updateStateToExpectedResult { + try DependencyValues.$_current.withValue(self.dependencies) { + try updateStateToExpectedResult(&expectedWhenGivenActualState) + } + } + expected = expectedWhenGivenActualState + + if expectedWhenGivenActualState != actual { + self.withExhaustivity(.on) { + expectationFailure(expected: expectedWhenGivenActualState) + } + } else if self.exhaustivity == .off(showSkippedAssertions: true) + && expectedWhenGivenActualState == actual + { + var expectedWhenGivenPreviousState = current + if let updateStateToExpectedResult = updateStateToExpectedResult { + _XCTExpectFailure(strict: false) { + do { + try DependencyValues.$_current.withValue(self.dependencies) { + try updateStateToExpectedResult(&expectedWhenGivenPreviousState) + } + } catch { + XCTFail( + """ + Skipped assertions: … + + Threw error: \(error) + """, + file: file, + line: line + ) + } + } + } + expected = expectedWhenGivenPreviousState + if expectedWhenGivenPreviousState != actual { + expectationFailure(expected: expectedWhenGivenPreviousState) + } else { + tryUnnecessaryModifyFailure() + } + } else { + tryUnnecessaryModifyFailure() + } + } + + func expectationFailure(expected: ScopedState) { + 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 = + updateStateToExpectedResult != nil + ? "A state change does not match expectation" + : "State was not expected to change, but a change occurred" + XCTFailHelper( + """ + \(messageHeading): … + + \(difference) + """, + file: file, + line: line + ) + } + + func tryUnnecessaryModifyFailure() { + guard expected == current && updateStateToExpectedResult != nil && failingWhenNothingChange + else { return } + XCTFailHelper( + """ + 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 + ) + } + } + private func withExhaustivity(_ exhaustivity: Exhaustivity, operation: () -> Void) { + let previous = self.exhaustivity + self.exhaustivity = exhaustivity + operation() + self.exhaustivity = previous + } +} + +extension TestStore where ScopedState: 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, message: "Call the async-friendly 'receive' instead.") + @available(macOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + @available(tvOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + @available(watchOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + public func receive( + _ expectedAction: Action, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) { + self.receiveAction( + matching: { expectedAction == $0 }, + failureMessage: #"Expected to receive an action "\#(expectedAction)", but didn't get one."#, + unexpectedActionDescription: { receivedAction in + 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)) + """ + } + }, + updateStateToExpectedResult, + file: file, + line: line + ) + } + /// Asserts a matching action was received from an effect and asserts how the state changes. + /// + /// See ``receive(_:timeout:assert:file:line:)-6b3xi`` for more information of how to use this + /// method. + /// + /// - Parameters: + /// - matchingAction: A closure that attempts to extract a value from an action. If it returns + /// `nil`, a test failure is reported. + /// - nanoseconds: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: 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, message: "Call the async-friendly 'receive' instead.") + @available(macOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + @available(tvOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + @available(watchOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + public func receive( + _ isMatching: (Action) -> Bool, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line ) { - assert(steps, groupLevel: groupLevel, file: file, line: line) + self.receiveAction( + matching: isMatching, + failureMessage: "Expected to receive a matching an action matching predicate, but didn't get one.", + unexpectedActionDescription: { receivedAction in + var action = "" + customDump(receivedAction, to: &action, indent: 2) + return action + }, + updateStateToExpectedResult, + file: file, + line: line + ) } - /// Asserts against an array of actions. - public func assert( - _ steps: [Step], - groupLevel: Int = 0, - file _: StaticString = #file, - line _: UInt = #line + /// Asserts an action was received matching a case path and asserts how the state changes. + /// + /// See ``receive(_:timeout:assert:file:line:)-5n755`` for more information of how to use this + /// method. + /// + /// - Parameters: + /// - actionCase: A case path identifying the case of an action to enum to receive + /// - updateStateToExpectedResult: 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, message: "Call the async-friendly 'receive' instead.") + @available(macOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + @available(tvOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + @available(watchOS, deprecated: 9999, message: "Call the async-friendly 'receive' instead.") + public func receive( + _ actionCase: CasePath, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line ) { - func assert(step: Step) { - switch step.type { - case let .send(action, update): - self.send(action, update, file: step.file, line: step.line) - case let .receive(expectedAction, update): - self.receive(expectedAction, update, file: step.file, line: step.line) - case let .environment(work): - 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 performing this work: … - Unhandled actions: \(actions) - """, - file: step.file, line: step.line - ) - } - do { - try work(&self.environment) - } catch { - XCTFail("Threw error: \(error)", file: step.file, line: step.line) - } - - case let .do(work): - 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 performing this work: … - Unhandled actions: \(actions) - """, - file: step.file, line: step.line - ) - } - do { - try work() - } catch { - XCTFail("Threw error: \(error)", file: step.file, line: step.line) + self.receiveAction( + matching: { actionCase.extract(from: $0) != nil }, + failureMessage: "Expected to receive an action matching case path, but didn't get one.", + unexpectedActionDescription: { receivedAction in + var action = "" + customDump(receivedAction, to: &action, indent: 2) + return action + }, + updateStateToExpectedResult, + file: file, + line: line + ) + } + + // NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. + // See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 + #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// When an effect is executed in your feature and sends an action back into the system, you + /// can use this method to assert that fact, and further assert how state changes after the + /// effect action is received: + /// + /// ```swift + /// await store.send(.buttontTapped) + /// await store.receive(.response(.success(42)) { + /// $0.count = 42 + /// } + /// ``` + /// + /// Due to the variability of concurrency in Swift, sometimes a small amount of time needs + /// to pass before effects execute and send actions, and that is why this method suspends. + /// The default time waited is very small, and typically it is enough so you should be + /// controlling your dependencies so that they do not wait for real world time to pass (see + /// for more information on how to do that). + /// + /// To change the amount of time this method waits for an action, pass an explicit `timeout` + /// argument, or set the ``timeout`` on the ``TestStore``. + /// + /// - Parameters: + /// - expectedAction: An action expected from an effect. + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: 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 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor + public func receive( + _ expectedAction: Action, + timeout duration: Duration, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + await self.receive( + expectedAction, + timeout: duration.nanoseconds, + assert: updateStateToExpectedResult, + file: file, + line: line + ) + } + + /// Asserts an action was received from an effect that matches a predicate, and asserts how + /// the state changes. + /// + /// This method is similar to ``receive(_:timeout:assert:file:line:)-5n755``, except it allows + /// you to assert that an action was received that matches a predicate without asserting + /// on all the data in the action: + /// + /// ```swift + /// await store.send(.buttonTapped) + /// await store.receive { + /// guard case .response(.suceess) = $0 else { return false } + /// return true + /// } assert: { + /// store.count = 42 + /// } + /// ``` + /// + /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a + /// grey information box will show next to the `store.receive` line in Xcode letting you know + /// what data was in the effect that you chose not to assert on. + /// + /// If you only want to check that a particular action case was received, then you might + /// find the ``receive(_:timeout:assert:file:line:)-5n755`` overload of this method more + /// useful. + /// + /// - Parameters: + /// - matchingAction: A closure that attempts to extract a value from an action. If it returns + /// `nil`, a test failure is reported. + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: 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 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor + @_disfavoredOverload + public func receive( + _ isMatching: (Action) -> Bool, + timeout duration: Duration, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + await self.receive( + isMatching, + timeout: duration.nanoseconds, + assert: updateStateToExpectedResult, + file: file, + line: line + ) + } + #endif + + /// Asserts an action was received from an effect and asserts how the state changes. + /// + /// See ``receive(_:timeout:assert:file:line:)-332q2`` for more information on how to use this + /// method. + /// + /// - Parameters: + /// - expectedAction: An action expected from an effect. + /// - nanoseconds: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: 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 + @_disfavoredOverload + public func receive( + _ expectedAction: Action, + timeout nanoseconds: UInt64? = nil, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) + }() + return + } + await self.receiveAction(timeout: nanoseconds, file: file, line: line) + _ = { + self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line) + }() + await Task.megaYield() + } + + /// Asserts a matching action was received from an effect and asserts how the state changes. + /// + /// See ``receive(_:timeout:assert:file:line:)-6b3xi`` for more information on how to use this + /// method. + /// + /// - Parameters: + /// - matchingAction: A closure that attempts to extract a value from an action. If it returns + /// `nil`, a test failure is reported. + /// - nanoseconds: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: 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 + @_disfavoredOverload + public func receive( + _ isMatching: (Action) -> Bool, + timeout nanoseconds: UInt64? = nil, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self.receive(isMatching, assert: updateStateToExpectedResult, file: file, line: line) + }() + return + } + await self.receiveAction(timeout: nanoseconds, file: file, line: line) + _ = { + self.receive(isMatching, assert: updateStateToExpectedResult, file: file, line: line) + }() + await Task.megaYield() + } + + /// Asserts an action was received matching a case path and asserts how the state changes. + /// + /// See ``receive(_:timeout:assert:file:line:)-5n755`` for more information of how to use this + /// method. + /// + /// - Parameters: + /// - casePath: A case path identifying the case of an action to enum to receive + /// - nanoseconds: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: 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 + @_disfavoredOverload + public func receive( + _ actionCase: CasePath, + timeout nanoseconds: UInt64? = nil, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) + }() + return + } + await self.receiveAction(timeout: nanoseconds, file: file, line: line) + _ = { + self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) + }() + await Task.megaYield() + } + +#if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) + /// Asserts an action was received matching a case path and asserts how the state changes. + /// + /// This method is similar to ``receive(_:timeout:assert:file:line:)-5n755``, except it allows + /// you to assert that an action was received that matches a particular case of the action + /// enum without asserting on all the data in the action. + /// + /// It can be useful to assert that a particular action was received without asserting + /// on the data inside the action. For example: + /// + /// ```swift + /// await store.receive(/Search.Action.searchResponse) { + /// $0.results = [ + /// "CasePaths", + /// "ComposableArchitecture", + /// "IdentifiedCollections", + /// "XCTestDynamicOverlay", + /// ] + /// } + /// ``` + /// + /// When the store's ``exhaustivity`` is set to anything other than ``Exhaustivity/off``, a + /// grey information box will show next to the `store.receive` line in Xcode letting you know + /// what data was in the effect that you chose not to assert on. + /// + /// - Parameters: + /// - actionCase: A case path identifying the case of an action to enum to receive + /// - duration: The amount of time to wait for the expected action. + /// - updateStateToExpectedResult: 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 + @_disfavoredOverload + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func receive( + _ actionCase: CasePath, + timeout duration: Duration, + assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) async { + guard !self.reducer.inFlightEffects.isEmpty + else { + _ = { + self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) + }() + return + } + await self.receiveAction(timeout: duration.nanoseconds, file: file, line: line) + _ = { + self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) + }() + await Task.megaYield() + } +#endif + + private func receiveAction( + matching predicate: (Action) -> Bool, + failureMessage: @autoclosure () -> String, + unexpectedActionDescription: (Action) -> String, + _ updateStateToExpectedResult: ((inout ScopedState) throws -> Void)?, + file: StaticString, + line: UInt + ) { + guard !self.reducer.receivedActions.isEmpty else { + XCTFail( + failureMessage(), + file: file, + line: line + ) + return + } + + if self.exhaustivity != .on { + guard self.reducer.receivedActions.contains(where: { predicate($0.action) }) else { + XCTFail( + failureMessage(), + file: file, + line: line + ) + return + } + + var actions: [Action] = [] + while let receivedAction = self.reducer.receivedActions.first, + !predicate(receivedAction.action) + { + actions.append(receivedAction.action) + self.withExhaustivity(.off) { + self.receive(receivedAction.action, file: file, line: line) } - case let .group(_, steps): - if steps.count > 0 { - self.assert( - steps, - groupLevel: groupLevel + 1, - file: step.file, - line: step.line - ) + } + + if !actions.isEmpty { + var action = "" + customDump(actions, to: &action) + XCTFailHelper( + """ + \(actions.count) received action\ + \(actions.count == 1 ? " was" : "s were") skipped: + + \(action) + """, + file: file, + line: line + ) + } + } + + let (receivedAction, state) = self.reducer.receivedActions.removeFirst() + if !predicate(receivedAction) { + XCTFailHelper( + """ + Received unexpected action: … + + \(unexpectedActionDescription(receivedAction)) + """, + file: file, + line: line + ) + } + let expectedState = self.toScopedState(self.state) + do { + try self.expectedStateShouldMatch( + expected: expectedState, + actual: self.toScopedState(state), + updateStateToExpectedResult: updateStateToExpectedResult, + file: file, + line: line + ) + } catch { + XCTFail("Threw error: \(error)", file: file, line: line) + } + self.reducer.state = state + if "\(self.file)" == "\(file)" { + self.line = line + } + } + + private func receiveAction( + timeout nanoseconds: UInt64?, + file: StaticString, + line: UInt + ) async { + let nanoseconds = nanoseconds ?? self.timeout + + await Task.megaYield() + let start = DispatchTime.now().uptimeNanoseconds + while !Task.isCancelled { + await Task.detached(priority: .background) { await Task.yield() }.value + + guard self.reducer.receivedActions.isEmpty + else { break } + + guard start.distance(to: DispatchTime.now().uptimeNanoseconds) < nanoseconds + else { + let suggestion: String + if self.reducer.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 \ + clock/scheduler (via "receive(on:)", "delay", "debounce", etc.), make sure that you \ + wait enough time for it to perform the effect. If you are using a test \ + clock/scheduler, advance it so that the effects may complete, or consider using \ + an immediate clock/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 } } - steps.forEach(assert(step:)) - - self.completed() + guard !Task.isCancelled + else { return } } } extension TestStore { - /// Scopes a store to assert against more local state and actions. + /// Scopes a store to assert against scoped 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 + /// - toScopedState: A function that transforms the reducer's state into scoped 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. + /// - fromScopedAction: A function that wraps a more scoped action in the reducer's action. + /// Scoped 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 { + state toScopedState: @escaping (ScopedState) -> S, + action fromScopedAction: @escaping (A) -> ScopedAction + ) -> TestStore { .init( - environment: environment, - file: file, - fromLocalAction: { self.fromLocalAction(fromLocalAction($0)) }, - initialState: store.state, - line: line, - reducer: reducer, - toLocalState: { toLocalState(self.toLocalState($0)) }, - failingWhenNothingChange: failingWhenNothingChange, - useNewScope: useNewScope + _environment: self._environment, + file: self.file, + fromScopedAction: { self.fromScopedAction(fromScopedAction($0)) }, + line: self.line, + reducer: self.reducer, + store: self.store, + timeout: self.timeout, + toScopedState: { toScopedState(self.toScopedState($0)) }, + failingWhenNothingChange: self.failingWhenNothingChange ) } - /// Scopes a store to assert against more local state. + /// Scopes a store to assert against scoped 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. + /// - Parameter toScopedState: A function that transforms the reducer's state into scoped 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 { - scope(state: toLocalState, action: { $0 }) + state toScopedState: @escaping (ScopedState) -> S + ) -> TestStore { + self.scope(state: toScopedState, action: { $0 }) + } + + /// Clears the queue of received actions from effects. + /// + /// Can be handy if you are writing an exhaustive test for a particular part of your feature, + /// but you don't want to explicitly deal with all of the received actions: + /// + /// ```swift + /// let store = TestStore(/* ... */) + /// + /// await store.send(.buttonTapped) { + /// // Assert on how state changed + /// } + /// await store.receive(.response(/* ... */)) { + /// // Assert on how state changed + /// } + /// + /// // Make it explicit you do not want to assert on any other received actions. + /// await store.skipReceivedActions() + /// ``` + /// + /// - Parameter strict: When `true` and there are no in-flight actions to cancel, a test failure + /// will be reported. + @MainActor + public func skipReceivedActions( + strict: Bool = true, + file: StaticString = #file, + line: UInt = #line + ) async { + await Task.megaYield() + _ = { self.skipReceivedActions(strict: strict, file: file, line: line) }() } - /// Deprecated - /// A single step of a `TestStore` assertion. - public struct Step { - internal let type: StepType - fileprivate let file: StaticString - fileprivate let line: UInt + /// Clears the queue of received actions from effects. + /// + /// The synchronous version of ``skipReceivedActions(strict:file:line:)-a4ri``. + /// + /// - Parameter strict: When `true` and there are no in-flight actions to cancel, a test failure + /// will be reported. + @available( + iOS, deprecated: 9999, message: "Call the async-friendly 'skipReceivedActions' instead." + ) + @available( + macOS, deprecated: 9999, message: "Call the async-friendly 'skipReceivedActions' instead." + ) + @available( + tvOS, deprecated: 9999, message: "Call the async-friendly 'skipReceivedActions' instead." + ) + @available( + watchOS, deprecated: 9999, message: "Call the async-friendly 'skipReceivedActions' instead." + ) + public func skipReceivedActions( + strict: Bool = true, + file: StaticString = #file, + line: UInt = #line + ) { + if strict && self.reducer.receivedActions.isEmpty { + XCTFail("There were no received actions to skip.") + return + } + guard !self.reducer.receivedActions.isEmpty + else { return } + var actions = "" + if self.reducer.receivedActions.count == 1 { + customDump(self.reducer.receivedActions[0].action, to: &actions) + } else { + customDump(self.reducer.receivedActions.map { $0.action }, to: &actions) + } + XCTFailHelper( + """ + \(self.reducer.receivedActions.count) received action\ + \(self.reducer.receivedActions.count == 1 ? " was" : "s were") skipped: - private init( - _ type: StepType, - file: StaticString = #file, - line: UInt = #line - ) { - self.type = type - self.file = file - self.line = line + \(actions) + """, + overrideExhaustivity: self.exhaustivity == .on + ? .off(showSkippedAssertions: true) + : self.exhaustivity, + file: file, + line: line + ) + self.reducer.state = self.reducer.receivedActions.last!.state + self.reducer.receivedActions = [] + } + + /// Cancels any currently in-flight effects. + /// + /// Can be handy if you are writing an exhaustive test for a particular part of your feature, + /// but you don't want to explicitly deal with all effects: + /// + /// ```swift + /// let store = TestStore(/* ... */) + /// + /// await store.send(.buttonTapped) { + /// // Assert on how state changed + /// } + /// await store.receive(.response(/* ... */)) { + /// // Assert on how state changed + /// } + /// + /// // Make it explicit you do not want to assert on how any other effects behave. + /// await store.skipInFlightEffects() + /// ``` + /// + /// - Parameter strict: When `true` and there are no in-flight actions to cancel, a test failure + /// will be reported. + public func skipInFlightEffects( + strict: Bool = true, + file: StaticString = #file, + line: UInt = #line + ) async { + await Task.megaYield() + _ = { self.skipInFlightEffects(strict: strict, file: file, line: line) }() + } + + /// Cancels any currently in-flight effects. + /// + /// The synchronous version of ``skipInFlightEffects(strict:file:line:)-5hbsk``. + /// + /// - Parameter strict: When `true` and there are no in-flight actions to cancel, a test failure + /// will be reported. + @available( + iOS, deprecated: 9999, message: "Call the async-friendly 'skipInFlightEffects' instead." + ) + @available( + macOS, deprecated: 9999, message: "Call the async-friendly 'skipInFlightEffects' instead." + ) + @available( + tvOS, deprecated: 9999, message: "Call the async-friendly 'skipInFlightEffects' instead." + ) + @available( + watchOS, deprecated: 9999, message: "Call the async-friendly 'skipInFlightEffects' instead." + ) + public func skipInFlightEffects( + strict: Bool = true, + file: StaticString = #file, + line: UInt = #line + ) { + if strict && self.reducer.inFlightEffects.isEmpty { + XCTFail("There were no in-flight effects to skip.") + return } + guard !self.reducer.inFlightEffects.isEmpty + else { return } - /// A step that describes an action sent to a store and asserts against how the store's state - /// is expected to change. - /// - /// - Parameters: - /// - action: An action to send to the test store. - /// - update: A function that describes how the test store's state is expected to change. - /// - Returns: A step that describes an action sent to a store and asserts against how the - /// store's state is expected to change. - public static func send( - _ action: LocalAction, - _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, - file: StaticString = #file, - line: UInt = #line - ) -> Step { - Step(.send(action, updateExpectingResult), file: file, line: line) + var actions = "" + if self.reducer.inFlightEffects.count == 1 { + customDump(self.reducer.inFlightEffects.first!.action.origin.action, to: &actions) + } else { + customDump(self.reducer.inFlightEffects.map { $0.action.origin.action }, to: &actions) } - /// A step that describes an action received by an effect and asserts against how the store's - /// state is expected to change. + XCTFailHelper( + """ + \(self.reducer.inFlightEffects.count) in-flight effect\ + \(self.reducer.inFlightEffects.count == 1 ? " was" : "s were") cancelled, originating from: + + \(actions) + """, + overrideExhaustivity: self.exhaustivity == .on + ? .off(showSkippedAssertions: true) + : self.exhaustivity, + file: file, + line: line + ) + + for effect in self.reducer.inFlightEffects { + _ = Effect.cancel(id: effect.id).subscribe(onNext: { _ in }) + } + self.reducer.inFlightEffects = [] + } + + private func XCTFailHelper( + _ message: String = "", + overrideExhaustivity exhaustivity: Exhaustivity? = nil, + file: StaticString, + line: UInt + ) { + let exhaustivity = exhaustivity ?? self.exhaustivity + switch exhaustivity { + case .on: + XCTFail(message, file: file, line: line) + case .off(showSkippedAssertions: true): + _XCTExpectFailure { + XCTFail( + """ + Skipped assertions: … + \(message) + """, + file: file, + line: line + ) + } + case .off(showSkippedAssertions: false): + break + } + } +} + +/// The type returned from ``TestStore/send(_:_:file:line:)-6s1gq`` 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:)`` 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 + + @_spi(Canary) public init(rawValue: Task?, timeout: UInt64) { + self.rawValue = rawValue + self.timeout = timeout + } + + /// Cancels the underlying task and waits for it to finish. + /// + /// This can be handy when a feature needs to start a long-living effect when the feature appears, + /// but cancellation of that effect is handled by the parent when the feature disappears. Such a + /// feature is difficult to exhaustively test in isolation because there is no action in its + /// domain that cancels the effect: + /// + /// ```swift + /// let store = TestStore(/* ... */) + /// + /// let onAppearTask = await store.send(.onAppear) + /// // Assert what is happening in the feature + /// + /// await onAppearTask.cancel() // ✅ Cancel the task to simulate the feature disappearing. + /// ``` + public func cancel() async { + self.rawValue?.cancel() + await self.rawValue?.cancellableValue + } + + // NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. + // See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 + #if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) + /// Asserts the underlying task finished. /// - /// - Parameters: - /// - action: An action the test store should receive by evaluating an effect. - /// - update: A function that describes how the test store's state is expected to change. - /// - Returns: A step that describes an action received by an effect and asserts against how - /// the store's state is expected to change. - public static func receive( - _ action: Action, - _ updateExpectingResult: ((inout LocalState) throws -> Void)? = nil, + /// - Parameter duration: The amount of time to wait before asserting. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public func finish( + timeout duration: Duration? = nil, file: StaticString = #file, line: UInt = #line - ) -> Step { - Step(.receive(action, updateExpectingResult), file: file, line: 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. + @_disfavoredOverload + 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 using a scheduler (via "sleep(for:)", \ + "timer(interval:)", 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. - /// A step that updates a test store's environment. - /// - /// - Parameter update: A function that updates the test store's environment for subsequent - /// steps. - /// - Returns: A step that updates a test store's environment. - public static func environment( - file: StaticString = #file, - line: UInt = #line, - _ update: @escaping (inout Environment) throws -> Void - ) -> Step { - Step(.environment(update), file: file, line: line) - } + 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" : ""). - /// A step that captures some work to be done between assertions - /// - /// - Parameter work: A function that is called between steps. - /// - Returns: A step that captures some work to be done between assertions. - public static func `do`( - file: StaticString = #file, - line: UInt = #line, - _ work: @escaping () throws -> Void - ) -> Step { - Step(.do(work), file: file, line: line) + \(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 + } +} + +internal class TestReducer: ReducerProtocol { + internal let base: Reduce + internal var dependencies = DependencyValues() + internal let effectDidSubscribe = AsyncStream.streamWithContinuation() + internal var inFlightEffects: Set = [] + internal var receivedActions: [(action: Action, state: State)] = [] + internal var state: State + + internal init( + _ base: Reduce, + initialState: State + ) { + self.base = base + self.state = initialState + } + + internal func reduce(into state: inout State, action: TestAction) -> Effect { + let reducer = self.base.dependency(\.self, self.dependencies) - /// A step that captures a sub-sequence of steps. - /// - /// - Parameter steps: An array of `Step` - /// - Returns: A step that captures a sub-sequence of steps. - public static func group( - _ name: String, - file: StaticString = #file, - line: UInt = #line, - _ steps: Step... - ) -> Step { - Step(.group(name, steps), file: file, line: line) + let effects: Effect + switch action.origin { + case let .send(action): + effects = reducer.reduce(into: &state, action: action) + self.state = state + + case let .receive(action): + effects = reducer.reduce(into: &state, action: action) + self.receivedActions.append((action, state)) } - internal enum StepType { - case send(LocalAction, ((inout LocalState) throws -> Void)?) - case receive(Action, ((inout LocalState) throws -> Void)?) - case environment((inout Environment) throws -> Void) - case `do`(() throws -> Void) - case group(String, [Step]) + switch effects.operation { + case .none: + self.effectDidSubscribe.continuation.yield() + return .none + + case .observable, .run: + let effect = LongLivingEffect(action: action) + return effects + .do( + onCompleted: { [weak self] in + self?.inFlightEffects.remove(effect) + }, + onSubscribe: { [weak self] in + self?.inFlightEffects.insert(effect) + + Task { [weak self] in + await Task.megaYield() + self?.effectDidSubscribe.continuation.yield() + } + }, + onDispose: { [weak self] in + self?.inFlightEffects.remove(effect) + } + ) + .map { .init(origin: .receive($0), file: action.file, line: action.line) } + .eraseToEffect() } } - public struct Annotating { - public typealias StepResultCallback = (Bool) -> Void + struct LongLivingEffect: Hashable { + let id = UUID() + let action: TestAction - public var annotate: (Step, Int, @escaping (@escaping StepResultCallback) -> Void) -> Void + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id + } - public init(annotate: @escaping (Step, Int, @escaping (@escaping StepResultCallback) -> Void) -> Void) { - self.annotate = annotate + func hash(into hasher: inout Hasher) { + self.id.hash(into: &hasher) } + } + + struct TestAction { + let origin: Origin + let file: StaticString + let line: UInt - public static func combine(_ annotatings: Annotating...) -> Self { - return Annotating { step, groupLevel, callback in - var combinedCallbacks: [StepResultCallback] = [] - - for annotating in annotatings { - annotating.annotate(step, groupLevel) { resultCallback in - combinedCallbacks.append(resultCallback) - } - } - - callback { stepResult in - for callback in combinedCallbacks { - callback(stepResult) - } + enum Origin { + case receive(Action) + case send(Action) + fileprivate var action: Action { + switch self { + case let .receive(action), let .send(action): + return action } } } - - public static var none: Self { - Self { _, _, callback in - callback { _ in } - } + } +} + +extension Task where Success == Never, Failure == Never { + @_spi(Internals) public static func megaYield(count: Int = 10) async { + for _ in 1...count { + await Task.detached(priority: .background) { await Task.yield() }.value } } } + +// NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library. +// See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15 +#if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst) + @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 + +/// The exhaustivity of assertions made by the test store. +public enum Exhaustivity: Equatable { + /// Exhaustive assertions. + /// + /// This setting requires you to exhaustively assert on all state changes and all actions received + /// from effects. Additionally, all in-flight effects _must_ be received before the test store is + /// deallocated. + /// + /// To manually skip actions or effects, use + /// ``TestStore/skipReceivedActions(strict:file:line:)-a4ri`` or + /// ``TestStore/skipInFlightEffects(strict:file:line:)-5hbsk``. + /// + /// To partially match an action received from an effect, use + /// ``TestStore/receive(_:timeout:assert:file:line:)-4e4m0``. + case on + + /// Non-exhaustive assertions. + /// + /// This settings allows you to assert on any subset of state changes and actions received from + /// effects. + /// + /// When configured to `showSkippedAssertions`, any state not asserted on or received actions + /// skipped will be reported in a grey informational box next to the assertion. This is handy for + /// when you want non-exhaustivity but you still want to know what all you are missing from your + /// assertions. + /// + /// - Parameter showSkippedAssertions: When `true`, skipped assertions will be reported as + /// expected failures. + case off(showSkippedAssertions: Bool) + + /// Non-exhaustive assertions. + public static let off = Self.off(showSkippedAssertions: false) +} + +@_transparent +private func _XCTExpectFailure( + _ failureReason: String? = nil, + strict: Bool = true, + failingBlock: () -> Void +) { +#if DEBUG + guard + let XCTExpectedFailureOptions = NSClassFromString("XCTExpectedFailureOptions") + as Any as? NSObjectProtocol, + let options = strict + ? XCTExpectedFailureOptions + .perform(NSSelectorFromString("alloc"))?.takeUnretainedValue() + .perform(NSSelectorFromString("init"))?.takeUnretainedValue() + : XCTExpectedFailureOptions + .perform(NSSelectorFromString("nonStrictOptions"))?.takeUnretainedValue() + else { return } + + let XCTExpectFailureWithOptionsInBlock = unsafeBitCast( + dlsym(dlopen(nil, RTLD_LAZY), "XCTExpectFailureWithOptionsInBlock"), + to: (@convention(c) (String?, AnyObject, () -> Void) -> Void).self + ) + + XCTExpectFailureWithOptionsInBlock(failureReason, options, failingBlock) +#endif +} + #endif diff --git a/Sources/RxComposableArchitecture/TestSupport/XCTFail.swift b/Sources/RxComposableArchitecture/TestSupport/XCTFail.swift deleted file mode 100644 index 79c2539..0000000 --- a/Sources/RxComposableArchitecture/TestSupport/XCTFail.swift +++ /dev/null @@ -1,94 +0,0 @@ -#if DEBUG - #if canImport(ObjectiveC) - import Foundation - - /// This function generates a failure immediately and unconditionally. - /// - /// Dynamically creates and records an `XCTIssue` under the hood that captures the source code - /// context of the caller. Useful for defining assertion helpers that fail in indirect code - /// paths, where the `file` and `line` of the failure have not been realized. - /// - /// - Parameter message: An optional description of the assertion, for inclusion in test - /// results. - internal func XCTFail(_ message: String = "") { - if let XCTestObservationCenter = NSClassFromString("XCTestObservationCenter") - as Any as? NSObjectProtocol, - String(describing: XCTestObservationCenter) != "", - let shared = XCTestObservationCenter.perform(Selector(("sharedTestObservationCenter")))? - .takeUnretainedValue(), - let observers = shared.perform(Selector(("observers")))? - .takeUnretainedValue() as? [AnyObject], - let observer = - observers - .first(where: { NSStringFromClass(type(of: $0)) == "XCTestMisuseObserver" }), - let currentTestCase = observer.perform(Selector(("currentTestCase")))? - .takeUnretainedValue(), - let XCTIssue = NSClassFromString("XCTIssue") - as Any as? NSObjectProtocol, - let alloc = XCTIssue.perform(NSSelectorFromString("alloc"))? - .takeUnretainedValue(), - let issue = - alloc - .perform( - Selector(("initWithType:compactDescription:")), - with: 0, - with: message.isEmpty ? "failed" : message - )? - .takeUnretainedValue() - { - _ = currentTestCase.perform(Selector(("recordIssue:")), with: issue) - return - } - } - - /// This function generates a failure immediately and unconditionally. - /// - /// Dynamically calls `XCTFail` with the given file and line. Useful for defining assertion - /// helpers that have the source code context at hand and want to highlight the direct caller - /// of the helper. - /// - /// - Parameter message: An optional description of the assertion, for inclusion in test - /// results. - internal func XCTFail(_ message: String = "", file: StaticString, line: UInt) { - guard let _XCTFailureHandler = _XCTFailureHandler - else { return } - - _XCTFailureHandler(nil, true, "\(file)", line, "\(message.isEmpty ? "failed" : message)", nil) - } - - private typealias XCTFailureHandler = @convention(c) ( - AnyObject?, Bool, UnsafePointer, UInt, String, String? - ) -> Void - private let XCTest = NSClassFromString("XCTest") - .flatMap(Bundle.init(for:)) - .flatMap { $0.executablePath } - .flatMap { dlopen($0, RTLD_NOW) } - private let _XCTFailureHandler = - XCTest - .flatMap { dlsym($0, "_XCTFailureHandler") } - .map { unsafeBitCast($0, to: XCTFailureHandler.self) } - #else - // NB: It seems to be safe to import XCTest on Linux - @_exported import func XCTest.XCTFail - #endif -#else - /// This function generates a failure immediately and unconditionally. - /// - /// Dynamically creates and records an `XCTIssue` under the hood that captures the source code - /// context of the caller. Useful for defining assertion helpers that fail in indirect code - /// paths, where the `file` and `line` of the failure have not been realized. - /// - /// - Parameter message: An optional description of the assertion, for inclusion in test - /// results. - internal func XCTFail(_ message: String = "") {} - - /// This function generates a failure immediately and unconditionally. - /// - /// Dynamically creates and records an `XCTIssue` under the hood that captures the source code - /// context of the caller. Useful for defining assertion helpers that fail in indirect code - /// paths, where the `file` and `line` of the failure have not been realized. - /// - /// - Parameter message: An optional description of the assertion, for inclusion in test - /// results. - internal func XCTFail(_ message: String = "", file: StaticString, line: UInt) {} -#endif diff --git a/Tests/RxComposableArchitectureTests/BootstrapTests.swift b/Tests/RxComposableArchitectureTests/BootstrapTests.swift index 40bc71c..7497eb8 100644 --- a/Tests/RxComposableArchitectureTests/BootstrapTests.swift +++ b/Tests/RxComposableArchitectureTests/BootstrapTests.swift @@ -16,7 +16,7 @@ internal final class BoostrapTests: XCTestCase { var getNumber: () -> Int } - let reducer = Reducer { state, action, env in + let reducer = AnyReducer { state, action, env in state = env.getNumber() return .none } @@ -30,13 +30,13 @@ internal final class BoostrapTests: XCTestCase { }) Bootstrap.mock(environment: mockEnv) - store.send(()) + _ = store.send(()) XCTAssertEqual(store.state, 100) // clearing the bootstrap Bootstrap.clear(environment: Env.self) - store.send(()) + _ = store.send(()) XCTAssertEqual(store.state, 0) } } diff --git a/Tests/RxComposableArchitectureTests/DependencyKeyWritingReducerTests.swift b/Tests/RxComposableArchitectureTests/DependencyKeyWritingReducerTests.swift new file mode 100644 index 0000000..72b68ac --- /dev/null +++ b/Tests/RxComposableArchitectureTests/DependencyKeyWritingReducerTests.swift @@ -0,0 +1,157 @@ +import RxComposableArchitecture +import XCTest + +@MainActor +final class DependencyKeyWritingReducerTests: XCTestCase { + func testWritingFusion() async { + let reducer: _DependencyKeyWritingReducer = Feature() + .dependency(\.myValue, 42) + .dependency(\.myValue, 1729) + .dependency(\.myValue, 1) + .dependency(\.myValue, 2) + .dependency(\.myValue, 3) + + XCTAssertTrue((reducer as Any) is _DependencyKeyWritingReducer) + } + + func testTransformFusion() async { + let reducer: _DependencyKeyWritingReducer = Feature() + .transformDependency(\.myValue) { $0 = 42 } + .transformDependency(\.myValue) { $0 = 1729 } + .transformDependency(\.myValue) { $0 = 1 } + .transformDependency(\.myValue) { $0 = 2 } + .transformDependency(\.myValue) { $0 = 3 } + + XCTAssertTrue((reducer as Any) is _DependencyKeyWritingReducer) + } + + func testWritingFusionOrder() async { + let reducer = Feature() + .dependency(\.myValue, 42) + .dependency(\.myValue, 1729) + + let store = TestStore( + initialState: Feature.State(), + reducer: reducer + ) + + await store.send(.tap) { + $0.value = 42 + } + } + + func testTransformFusionOrder() async { + let reducer = Feature() + .transformDependency(\.myValue) { $0 = 42 } + .transformDependency(\.myValue) { $0 = 1729 } + + let store = TestStore( + initialState: Feature.State(), + reducer: reducer + ) + + await store.send(.tap) { + $0.value = 42 + } + } + + func testWritingOrder() async { + let reducer = CombineReducers { + Feature() + .dependency(\.myValue, 42) + } + .dependency(\.myValue, 1729) + + let store = TestStore( + initialState: Feature.State(), + reducer: reducer + ) + + await store.send(.tap) { + $0.value = 42 + } + } + + func testTransformOrder() async { + let reducer = CombineReducers { + Feature() + .transformDependency(\.myValue) { $0 = 42 } + } + .transformDependency(\.myValue) { $0 = 1729 } + + let store = TestStore( + initialState: Feature.State(), + reducer: reducer + ) + + await store.send(.tap) { + $0.value = 42 + } + } + + func testDependency_EffectOfEffect() async { + struct Feature: ReducerProtocol { + struct State: Equatable { var count = 0 } + enum Action: Equatable { + case tap + case response(Int) + case otherResponse(Int) + } + @Dependency(\.myValue) var myValue + + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .tap: + state.count += 1 + return .task { .response(self.myValue) } + + case let .response(value): + state.count = value + return .task { .otherResponse(self.myValue) } + + case let .otherResponse(value): + state.count = value + return .none + } + } + } + + let store = TestStore( + initialState: Feature.State(), + reducer: Feature() + .dependency(\.myValue, 42) + ) + + await store.send(.tap) { + $0.count = 1 + } + await store.receive(.response(42)) { + $0.count = 42 + } + await store.receive(.otherResponse(42)) + } +} + +private struct Feature: ReducerProtocol { + @Dependency(\.myValue) var myValue + struct State: Equatable { var value = 0 } + enum Action { case tap } + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .tap: + state.value = self.myValue + return .none + } + } +} + +private enum MyValue: DependencyKey { + static let liveValue = 0 + static let testValue = 0 +} +extension DependencyValues { + var myValue: Int { + get { self[MyValue.self] } + set { self[MyValue.self] = newValue } + } +} diff --git a/Tests/RxComposableArchitectureTests/DependencyTests/DependencyKeyTests.swift b/Tests/RxComposableArchitectureTests/DependencyTests/DependencyKeyTests.swift new file mode 100644 index 0000000..04955f4 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/DependencyTests/DependencyKeyTests.swift @@ -0,0 +1,159 @@ +import RxComposableArchitecture +import XCTest + +final class DependencyKeyTests: XCTestCase { + func testTestDependencyKey_ImplementOnlyTestValue() { + enum Key: TestDependencyKey { + static let testValue = 42 + } + + XCTAssertEqual(42, Key.previewValue) + XCTAssertEqual(42, Key.testValue) + } + + func testDependencyKeyCascading_ValueIsSelf_ImplementOnlyLiveValue() { + struct Dependency: DependencyKey { + let value: Int + static let liveValue = Self(value: 42) + } + + XCTAssertEqual(42, Dependency.liveValue.value) + XCTAssertEqual(42, Dependency.previewValue.value) + + #if DEBUG + XCTExpectFailure { + XCTAssertEqual(42, Dependency.testValue.value) + } issueMatcher: { issue in + issue.compactDescription == """ + A dependency has no test implementation, but was accessed from a test context: + + Dependency: + DependencyKeyTests.Dependency + + Dependencies registered with the library are not allowed to use their default, live \ + implementations when run from tests. + + To fix, override the dependency with a mock value in your test. If you are using the \ + Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. \ + Otherwise, use 'DependencyValues.withValues' to define a scope for the override. If \ + you'd like to provide a default value for all tests, implement the 'testValue' \ + requirement of the 'DependencyKey' protocol. + """ + } + #endif + } + + func testDependencyKeyCascading_ImplementOnlyLiveValue() { + enum Key: DependencyKey { + static let liveValue = 42 + } + + XCTAssertEqual(42, Key.liveValue) + XCTAssertEqual(42, Key.previewValue) + + #if DEBUG + XCTExpectFailure { + XCTAssertEqual(42, Key.testValue) + } issueMatcher: { issue in + issue.compactDescription == """ + A dependency has no test implementation, but was accessed from a test context: + + Key: + DependencyKeyTests.Key + Value: + Int + + Dependencies registered with the library are not allowed to use their default, live \ + implementations when run from tests. + + To fix, override the dependency with a mock value in your test. If you are using the \ + Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. \ + Otherwise, use 'DependencyValues.withValues' to define a scope for the override. If \ + you'd like to provide a default value for all tests, implement the 'testValue' \ + requirement of the 'DependencyKey' protocol. + """ + } + #endif + } + + func testDependencyKeyCascading_ImplementOnlyLiveAndPreviewValue() { + enum Key: DependencyKey { + static let liveValue = 42 + static let previewValue = 1729 + } + + XCTAssertEqual(42, Key.liveValue) + XCTAssertEqual(1729, Key.previewValue) + + #if DEBUG + XCTExpectFailure { + XCTAssertEqual(42, Key.testValue) + } issueMatcher: { issue in + issue.compactDescription == """ + A dependency has no test implementation, but was accessed from a test context: + + Key: + DependencyKeyTests.Key + Value: + Int + + Dependencies registered with the library are not allowed to use their default, live \ + implementations when run from tests. + + To fix, override the dependency with a mock value in your test. If you are using the \ + Composable Architecture, mutate the 'dependencies' property on your 'TestStore'. \ + Otherwise, use 'DependencyValues.withValues' to define a scope for the override. If \ + you'd like to provide a default value for all tests, implement the 'testValue' \ + requirement of the 'DependencyKey' protocol. + """ + } + #endif + } + + func testDependencyKeyCascading_ImplementOnlyLive_Named() { + #if DEBUG + DependencyValues.withValues { + $0.context = .test + } operation: { + @Dependency(\.missingTestDependency) var missingTestDependency: Int + let line = #line - 1 + XCTExpectFailure { + XCTAssertEqual(42, missingTestDependency) + } issueMatcher: { issue in + print("<<< da-dbg: \(issue.compactDescription)") + return issue.compactDescription == """ + @Dependency(\\.missingTestDependency) has no test implementation, but was accessed \ + from a test context: + + Location: + RxComposableArchitectureTests/DependencyKeyTests.swift:\(line) + Key: + LiveKey + Value: + Int + + Dependencies registered with the library are not allowed to use their default, live \ + implementations when run from tests. + + To fix, override 'missingTestDependency' with a mock value in your test. If you are \ + using the Composable Architecture, mutate the 'dependencies' property on your \ + 'TestStore'. Otherwise, use 'DependencyValues.withValues' to define a scope for the \ + override. If you'd like to provide a default value for all tests, implement the \ + 'testValue' requirement of the 'DependencyKey' protocol. + """ + } + } + #endif + } +} + +private enum LiveKey: DependencyKey { + static let liveValue = 42 +} + +extension DependencyValues { + fileprivate var missingTestDependency: Int { + get { self[LiveKey.self] } + set { self[LiveKey.self] = newValue } + } +} diff --git a/Tests/RxComposableArchitectureTests/DependencyTests/DependencyValuesTests.swift b/Tests/RxComposableArchitectureTests/DependencyTests/DependencyValuesTests.swift new file mode 100644 index 0000000..f0b702e --- /dev/null +++ b/Tests/RxComposableArchitectureTests/DependencyTests/DependencyValuesTests.swift @@ -0,0 +1,280 @@ +import RxComposableArchitecture +import XCTest + +final class DependencyValuesTests: XCTestCase { + func testMissingLiveValue() { + #if DEBUG + var line = 0 + XCTExpectFailure { + var values = DependencyValues._current + values.context = .live + DependencyValues.$_current.withValue(values) { + line = #line + 1 + @Dependency(\.missingLiveDependency) var missingLiveDependency: Int + _ = missingLiveDependency + } + } issueMatcher: { + $0.compactDescription == """ + "@Dependency(\\.missingLiveDependency)" has no live implementation, but was accessed \ + from a live context. + + Location: + RxComposableArchitectureTests/DependencyValuesTests.swift:\(line) + Key: + TestKey + Value: + Int + + Every dependency registered with the library must conform to "DependencyKey", and that \ + conformance must be visible to the running application. + + To fix, make sure that "TestKey" conforms to "DependencyKey" by providing a live \ + implementation of your dependency, and make sure that the conformance is linked with \ + this current application. + """ + } + #endif + } + + func testWithValues() { + let date = DependencyValues.withValues { + $0.date = .constant(someDate) + } operation: { () -> Date in + @Dependency(\.date) var date + return date.now + } + + let defaultDate = DependencyValues.withValues { + $0.context = .live + } operation: { () -> Date in + @Dependency(\.date) var date + return date.now + } + + XCTAssertEqual(date, someDate) + XCTAssertNotEqual(defaultDate, someDate) + } + + func testWithValue() { + DependencyValues.withValue(\.context, .live) { + let date = DependencyValues.withValue(\.date, .constant(someDate)) { () -> Date in + @Dependency(\.date) var date + return date.now + } + + XCTAssertEqual(date, someDate) + XCTAssertNotEqual(DependencyValues._current.date.now, someDate) + } + } + + func testDependencyDefaultIsReused() { + DependencyValues.withValue(\.self, .init()) { + DependencyValues.withValue(\.context, .test) { + @Dependency(\.reuseClient) var reuseClient: ReuseClient + + XCTAssertEqual(reuseClient.count(), 0) + reuseClient.setCount(42) + XCTAssertEqual(reuseClient.count(), 42) + } + } + } + + func testDependencyDefaultIsReused_SegmentedByContext() { + DependencyValues.withValue(\.self, .init()) { + DependencyValues.withValue(\.context, .test) { + @Dependency(\.reuseClient) var reuseClient: ReuseClient + + XCTAssertEqual(reuseClient.count(), 0) + reuseClient.setCount(42) + XCTAssertEqual(reuseClient.count(), 42) + + DependencyValues.withValue(\.context, .preview) { + XCTAssertEqual(reuseClient.count(), 0) + reuseClient.setCount(1729) + XCTAssertEqual(reuseClient.count(), 1729) + } + + XCTAssertEqual(reuseClient.count(), 42) + + DependencyValues.withValue(\.context, .live) { + #if DEBUG + XCTExpectFailure { + $0.compactDescription.contains( + """ + @Dependency(\\.reuseClient)" has no live implementation, but was accessed from a live \ + context. + """ + ) + } + #endif + XCTAssertEqual(reuseClient.count(), 0) + reuseClient.setCount(-42) + XCTAssertEqual( + reuseClient.count(), + 0, + "Don't cache dependency when using a test value in a live context" + ) + } + + XCTAssertEqual(reuseClient.count(), 42) + } + } + } + + func testAccessingTestDependencyFromLiveContext_WhenUpdatingDependencies() { + @Dependency(\.reuseClient) var reuseClient: ReuseClient + + DependencyValues.withValue(\.context, .live) { + DependencyValues.withValues { + XCTAssertEqual($0.reuseClient.count(), 0) + XCTAssertEqual(reuseClient.count(), 0) + } operation: { + #if DEBUG + XCTExpectFailure { + $0.compactDescription.contains( + """ + @Dependency(\\.reuseClient)" has no live implementation, but was accessed from a live \ + context. + """ + ) + } + #endif + XCTAssertEqual(reuseClient.count(), 0) + } + } + } + + func testBinding() { + DependencyValues.withValue(\.context, .test) { + @Dependency(\.childDependencyEarlyBinding) var childDependencyEarlyBinding: + ChildDependencyEarlyBinding + @Dependency(\.childDependencyLateBinding) var childDependencyLateBinding: + ChildDependencyLateBinding + + XCTAssertEqual(childDependencyEarlyBinding.fetch(), 42) + XCTAssertEqual(childDependencyLateBinding.fetch(), 42) + + DependencyValues.withValue(\.someDependency.fetch, { 1729 }) { + XCTAssertEqual(childDependencyEarlyBinding.fetch(), 1729) + XCTAssertEqual(childDependencyLateBinding.fetch(), 1729) + } + + var childDependencyEarlyBindingEscaped: ChildDependencyEarlyBinding! + var childDependencyLateBindingEscaped: ChildDependencyLateBinding! + + DependencyValues.withValue(\.someDependency.fetch, { 999 }) { + @Dependency(\.childDependencyEarlyBinding) var childDependencyEarlyBinding2: + ChildDependencyEarlyBinding + @Dependency(\.childDependencyLateBinding) var childDependencyLateBinding2: + ChildDependencyLateBinding + + childDependencyEarlyBindingEscaped = childDependencyEarlyBinding + childDependencyLateBindingEscaped = childDependencyLateBinding + + XCTAssertEqual(childDependencyEarlyBinding2.fetch(), 999) + XCTAssertEqual(childDependencyLateBinding2.fetch(), 999) + } + + XCTAssertEqual(childDependencyEarlyBindingEscaped.fetch(), 42) + XCTAssertEqual(childDependencyLateBindingEscaped.fetch(), 42) + + DependencyValues.withValue(\.someDependency.fetch, { 1_000 }) { + XCTAssertEqual(childDependencyEarlyBindingEscaped.fetch(), 1_000) + XCTAssertEqual(childDependencyLateBindingEscaped.fetch(), 1_000) + } + } + } + + func testNestedDependencyIsOverridden() { + DependencyValues.withValue(\.nestedValue.value, 10) { + @Dependency(\.nestedValue) var nestedValue: NestedValue + @Dependency(\.nestedValue.value) var value: Int + XCTAssertEqual(nestedValue.value, 10) + XCTAssertEqual(value, 10) + } + } +} + +private let someDate = Date(timeIntervalSince1970: 1_234_567_890) + +struct NestedValue: TestDependencyKey { + static var testValue: Self { .init() } + var value: Int = 0 +} + +private enum TestKey: TestDependencyKey { + static let testValue = 42 +} + +struct SomeDependency: TestDependencyKey { + var fetch: () -> Int + static let testValue = Self { 42 } +} + +struct ChildDependencyEarlyBinding: TestDependencyKey { + var fetch: () -> Int + static var testValue: Self { + @Dependency(\.someDependency) var someDependency + return Self { someDependency.fetch() } + } +} + +struct ChildDependencyLateBinding: TestDependencyKey { + var fetch: () -> Int + static var testValue: Self { + return Self { + @Dependency(\.someDependency) var someDependency + return someDependency.fetch() + } + } +} + +extension DependencyValues { + fileprivate var missingLiveDependency: Int { + self[TestKey.self] + } + + var someDependency: SomeDependency { + get { self[SomeDependency.self] } + set { self[SomeDependency.self] = newValue } + } + + var childDependencyEarlyBinding: ChildDependencyEarlyBinding { + get { self[ChildDependencyEarlyBinding.self] } + set { self[ChildDependencyEarlyBinding.self] = newValue } + } + + var childDependencyLateBinding: ChildDependencyLateBinding { + get { self[ChildDependencyLateBinding.self] } + set { self[ChildDependencyLateBinding.self] = newValue } + } + + fileprivate var reuseClient: ReuseClient { + get { self[ReuseClient.self] } + set { self[ReuseClient.self] = newValue } + } + + var nestedValue: NestedValue { + get { self[NestedValue.self] } + set { self[NestedValue.self] = newValue } + } +} + +struct ReuseClient: TestDependencyKey { + var count: () -> Int + var setCount: (Int) -> Void + init( + count: @escaping () -> Int, + setCount: @escaping (Int) -> Void + ) { + self.count = count + self.setCount = setCount + } + static var testValue: Self { + var count = 0 + return Self( + count: { count }, + setCount: { count = $0 } + ) + } +} diff --git a/Tests/RxComposableArchitectureTests/EffectCancellationTests.swift b/Tests/RxComposableArchitectureTests/EffectCancellationTests.swift index 285ca5a..80dd8e5 100644 --- a/Tests/RxComposableArchitectureTests/EffectCancellationTests.swift +++ b/Tests/RxComposableArchitectureTests/EffectCancellationTests.swift @@ -1,10 +1,10 @@ import RxSwift -//import TestSupport import XCTest -@testable import RxComposableArchitecture +@_spi(Internals) import RxComposableArchitecture internal final class EffectCancellationTests: XCTestCase { + struct CancelID: Hashable {} private var disposeBag = DisposeBag() override internal func tearDown() { @@ -13,12 +13,11 @@ internal final class EffectCancellationTests: XCTestCase { } internal func testCancellation() { - struct CancelToken: Hashable {} var values: [Int] = [] let subject = PublishSubject() let effect = Effect(subject) - .cancellable(id: CancelToken()) + .cancellable(id: CancelID()) effect.subscribe(onNext: { values.append($0) }) .disposed(by: disposeBag) @@ -29,7 +28,7 @@ internal final class EffectCancellationTests: XCTestCase { subject.onNext(2) XCTAssertEqual(values, [1, 2]) - Effect.cancel(id: CancelToken()) + Effect.cancel(id: CancelID()) .subscribe() .disposed(by: disposeBag) @@ -38,12 +37,11 @@ internal final class EffectCancellationTests: XCTestCase { } internal func testCancelInFlight() { - struct CancelToken: Hashable {} var values: [Int] = [] let subject = PublishSubject() Effect(subject) - .cancellable(id: CancelToken(), cancelInFlight: true) + .cancellable(id: CancelID(), cancelInFlight: true) .subscribe(onNext: { values.append($0) }) .disposed(by: disposeBag) @@ -54,7 +52,7 @@ internal final class EffectCancellationTests: XCTestCase { XCTAssertEqual(values, [1, 2]) Effect(subject) - .cancellable(id: CancelToken(), cancelInFlight: true) + .cancellable(id: CancelID(), cancelInFlight: true) .subscribe(onNext: { values.append($0) }) .disposed(by: disposeBag) @@ -65,20 +63,19 @@ internal final class EffectCancellationTests: XCTestCase { } internal func testCancellationAfterDelay() { - struct CancelToken: Hashable {} var value: Int? Observable.just(1) .delay(.milliseconds(500), scheduler: MainScheduler.instance) .eraseToEffect() - .cancellable(id: CancelToken()) + .cancellable(id: CancelID()) .subscribe(onNext: { value = $0 }) .disposed(by: disposeBag) XCTAssertEqual(value, nil) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - _ = Effect.cancel(id: CancelToken()) + _ = Effect.cancel(id: CancelID()) .subscribe() .disposed(by: self.disposeBag) } @@ -89,8 +86,6 @@ internal final class EffectCancellationTests: XCTestCase { } internal func testCancellationAfterDelay_WithTestScheduler() { - struct CancelToken: Hashable {} - let scheduler = TestScheduler(initialClock: 0) var value: Int? @@ -98,7 +93,7 @@ internal final class EffectCancellationTests: XCTestCase { Observable.just(1) .delay(.seconds(2), scheduler: scheduler) .eraseToEffect() - .cancellable(id: CancelToken()) + .cancellable(id: CancelID()) .subscribe(onNext: { value = $0 }) .disposed(by: disposeBag) @@ -106,50 +101,52 @@ internal final class EffectCancellationTests: XCTestCase { scheduler.advance(by: .seconds(1)) - Effect.cancel(id: CancelToken()) + Effect.cancel(id: CancelID()) .subscribe() .disposed(by: disposeBag) - scheduler.advance(to: 1000) + scheduler.run() XCTAssertEqual(value, nil) } internal func testCancellablesCleanUp_OnComplete() { + let id = UUID() Observable.just(1) .eraseToEffect() - .cancellable(id: 1) + .cancellable(id: id) .subscribe() .disposed(by: disposeBag) - XCTAssertNoDifference([:], cancellationCancellables) + XCTAssertNoDifference([:], _cancellationCancellables) } internal func testCancellablesCleanUp_OnCancel() { + let id = UUID() + let scheduler = TestScheduler(initialClock: 0) Observable.just(1) .delay(.seconds(1), scheduler: scheduler) .eraseToEffect() - .cancellable(id: 1) + .cancellable(id: id) .subscribe() .disposed(by: disposeBag) - Effect.cancel(id: 1) + Effect.cancel(id: id) .subscribe() .disposed(by: disposeBag) - XCTAssertTrue(cancellationCancellables.isEmpty) + XCTAssertTrue(_cancellationCancellables.isEmpty) } internal func testDoubleCancellation() { - struct CancelToken: Hashable {} var values: [Int] = [] let subject = PublishSubject() let effect = Effect(subject) - .cancellable(id: CancelToken()) - .cancellable(id: CancelToken()) + .cancellable(id: CancelID()) + .cancellable(id: CancelID()) effect .subscribe(onNext: { values.append($0) }) @@ -159,7 +156,7 @@ internal final class EffectCancellationTests: XCTestCase { subject.onNext(1) XCTAssertEqual(values, [1]) - _ = Effect.cancel(id: CancelToken()) + Effect.cancel(id: CancelID()) .subscribe() .disposed(by: disposeBag) @@ -168,12 +165,11 @@ internal final class EffectCancellationTests: XCTestCase { } internal func testCompleteBeforeCancellation() { - struct CancelToken: Hashable {} var values: [Int] = [] let subject = PublishSubject() let effect = Effect(subject) - .cancellable(id: CancelToken()) + .cancellable(id: CancelID()) effect .subscribe(onNext: { values.append($0) }) @@ -185,7 +181,7 @@ internal final class EffectCancellationTests: XCTestCase { subject.onCompleted() XCTAssertEqual(values, [1]) - Effect.cancel(id: CancelToken()) + Effect.cancel(id: CancelID()) .subscribe() .disposed(by: disposeBag) @@ -193,12 +189,14 @@ internal final class EffectCancellationTests: XCTestCase { } internal func testNestedCancels() { + let id = UUID() + var effect = Observable.never() .eraseToEffect() .cancellable(id: 1) for _ in 1 ... .random(in: 1 ... 1000) { - effect = effect.cancellable(id: 1) + effect = effect.cancellable(id: id) } effect @@ -207,7 +205,7 @@ internal final class EffectCancellationTests: XCTestCase { disposeBag = DisposeBag() - XCTAssertNoDifference([:], cancellationCancellables) + XCTAssertNoDifference([:], _cancellationCancellables) } internal func testSharedId() { diff --git a/Tests/RxComposableArchitectureTests/EffectDebounceTests.swift b/Tests/RxComposableArchitectureTests/EffectDebounceTests.swift index a4f3093..4485727 100644 --- a/Tests/RxComposableArchitectureTests/EffectDebounceTests.swift +++ b/Tests/RxComposableArchitectureTests/EffectDebounceTests.swift @@ -3,6 +3,7 @@ import RxSwift import RxComposableArchitecture import XCTest +@MainActor internal final class EffectDebounceTests: XCTestCase { private let disposeBag = DisposeBag() @@ -10,7 +11,8 @@ internal final class EffectDebounceTests: XCTestCase { let scheduler = TestScheduler(initialClock: 0) var values: [Int] = [] - func runDebouncedEffect(value: Int) { + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runDebouncedEffect(value: Int) { struct CancelToken: Hashable {} Observable.just(value) .eraseToEffect() @@ -57,7 +59,8 @@ internal final class EffectDebounceTests: XCTestCase { var values: [Int] = [] var effectRuns = 0 - func runDebouncedEffect(value: Int) { + // NB: Explicit @MainActor is needed for Swift 5.5.2 + @MainActor func runDebouncedEffect(value: Int) { struct CancelToken: Hashable {} Observable.deferred { () -> Observable in diff --git a/Tests/RxComposableArchitectureTests/EffectOperationTests.swift b/Tests/RxComposableArchitectureTests/EffectOperationTests.swift new file mode 100644 index 0000000..749b1a8 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/EffectOperationTests.swift @@ -0,0 +1,152 @@ +#if DEBUG +import XCTest + +@testable import RxComposableArchitecture + +@MainActor +class EffectOperationTests: XCTestCase { + func testMergeDiscardsNones() async { + var effect = Effect.none + .merge(with: .none) + switch effect.operation { + case .none: + XCTAssertTrue(true) + default: + XCTFail() + } + + effect = Effect.task { 42 } + .merge(with: .none) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, 42) })) + default: + XCTFail() + } + + effect = Effect.none + .merge(with: .task { 42 }) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, 42) })) + default: + XCTFail() + } + + effect = Effect.run { await $0(42) } + .merge(with: .none) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, 42) })) + default: + XCTFail() + } + + effect = Effect.none + .merge(with: .run { await $0(42) }) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, 42) })) + default: + XCTFail() + } + } + + func testConcatenateDiscardsNones() async { + var effect = Effect.none + .concatenate(with: .none) + switch effect.operation { + case .none: + XCTAssertTrue(true) + default: + XCTFail() + } + + effect = Effect.task { 42 } + .concatenate(with: .none) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, 42) })) + default: + XCTFail() + } + + effect = Effect.none + .concatenate(with: .task { 42 }) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, 42) })) + default: + XCTFail() + } + + effect = Effect.run { await $0(42) } + .concatenate(with: .none) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, 42) })) + default: + XCTFail() + } + + effect = Effect.none + .concatenate(with: .run { await $0(42) }) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, 42) })) + default: + XCTFail() + } + } + + func testMergeFuses() async { + var values = [Int]() + + let effect = Effect.task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC / 10) + return 42 + } + .merge( + with: .task { + try await Task.sleep(nanoseconds: NSEC_PER_SEC / 2) + return 1729 + } + ) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { values.append($0) })) + default: + XCTFail() + } + + XCTAssertEqual(values, [42, 1729]) + } + + func testConcatenateFuses() async { + var values = [Int]() + + let effect = Effect.task { 42 } + .concatenate(with: .task { 1729 }) + switch effect.operation { + case let .run(_, send): + await send(.init(send: { values.append($0) })) + default: + XCTFail() + } + + XCTAssertEqual(values, [42, 1729]) + } + + func testMap() async { + let effect = Effect.task { 42 } + .map { "\($0)" } + + switch effect.operation { + case let .run(_, send): + await send(.init(send: { XCTAssertEqual($0, "42") })) + default: + XCTFail() + } + } +} +#endif diff --git a/Tests/RxComposableArchitectureTests/EffectRunTests.swift b/Tests/RxComposableArchitectureTests/EffectRunTests.swift new file mode 100644 index 0000000..d6b9f0b --- /dev/null +++ b/Tests/RxComposableArchitectureTests/EffectRunTests.swift @@ -0,0 +1,119 @@ +import RxComposableArchitecture +import XCTest + +@MainActor +final class EffectRunTests: XCTestCase { + func testRun() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reduce { 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) + await store.send(.tapped) + await store.receive(.response) + } + + func testRunCatch() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reduce { 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) + await store.send(.tapped) + await store.receive(.response) + } + + #if DEBUG + func testRunUnhandledFailure() async { + var line: UInt! + XCTExpectFailure(nil, enabled: nil, strict: nil) { + $0.compactDescription == """ + An "Effect.run" returned from "\(#fileID):\(line+1)" 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 = Reduce { state, action in + switch action { + case .tapped: + line = #line + return .run { send in + struct Failure: Error {} + throw Failure() + } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer) + // 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 = Reduce { state, action in + switch action { + case .tapped: + return .run { send in + 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) + await store.send(.tapped).finish() + } + + func testRunCancellationCatch() async { + enum CancelID {} + struct State: Equatable {} + enum Action: Equatable { case tapped, responseA, responseB } + let reducer = Reduce { state, action in + switch action { + case .tapped: + return .run { send in + 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) + await store.send(.tapped).finish() + } +} diff --git a/Tests/RxComposableArchitectureTests/EffectTaskTests.swift b/Tests/RxComposableArchitectureTests/EffectTaskTests.swift new file mode 100644 index 0000000..6c4d734 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/EffectTaskTests.swift @@ -0,0 +1,119 @@ +import RxComposableArchitecture +import XCTest + +@MainActor +final class EffectTaskTests: XCTestCase { + func testTask() async { + struct State: Equatable {} + enum Action: Equatable { case tapped, response } + let reducer = Reduce { state, action in + switch action { + case .tapped: + return .task { .response } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer) + await store.send(.tapped) + await store.receive(.response) + } + + func testTaskCatch() async { + struct State: Equatable {} + enum Action: Equatable, Sendable { case tapped, response } + let reducer = Reduce { 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) + await store.send(.tapped) + await store.receive(.response) + } + + #if DEBUG + func testTaskUnhandledFailure() async { + var line: UInt! + XCTExpectFailure(nil, enabled: nil, strict: nil) { + $0.compactDescription == """ + An "Effect.task" returned from "\(#fileID):\(line+1)" 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 = Reduce { state, action in + switch action { + case .tapped: + line = #line + return .task { + struct Failure: Error {} + throw Failure() + } + case .response: + return .none + } + } + let store = TestStore(initialState: State(), reducer: reducer) + // 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 = Reduce { state, action in + switch action { + case .tapped: + return .task { + 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) + await store.send(.tapped).finish() + } + + func testTaskCancellationCatch() async { + enum CancelID {} + struct State: Equatable {} + enum Action: Equatable { case tapped, responseA, responseB } + let reducer = Reduce { state, action in + switch action { + case .tapped: + return .task { + 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) + await store.send(.tapped).finish() + } +} diff --git a/Tests/RxComposableArchitectureTests/EffectTests.swift b/Tests/RxComposableArchitectureTests/EffectTests.swift index 59ccd91..d78b0ee 100644 --- a/Tests/RxComposableArchitectureTests/EffectTests.swift +++ b/Tests/RxComposableArchitectureTests/EffectTests.swift @@ -8,11 +8,66 @@ import RxSwift import XCTest -@testable import RxComposableArchitecture +@_spi(Canary) import RxComposableArchitecture +@MainActor internal final class EffectTests: XCTestCase { private var disposeBag = DisposeBag() private let scheduler = TestScheduler(initialClock: 0) + + func testCatchToEffect() { + struct MyError: Swift.Error, Equatable {} + + Observable.just(42) + .catchToEffect() + .subscribe(onNext: { + guard case let .success(intValue) = $0 else { + XCTFail("Value is not Success one") + return + } + XCTAssertEqual(intValue, 42) + }) + .disposed(by: disposeBag) + + Observable.error(MyError()) + .eraseToEffect() + .subscribe(onError: { + guard let err = $0 as? MyError else { + XCTFail("Error is not MyError") + return + } + XCTAssertEqual(err, MyError()) + }) + .disposed(by: disposeBag) + Observable.just(42) + .eraseToEffect() + .subscribe(onNext: { XCTAssertEqual($0, 42) }) + .disposed(by: disposeBag) + + Observable.just(42) + .catchToEffect{ + switch $0 { + case let .success(val): + return val + case .failure: + return -1 + } + } + .subscribe(onNext: { XCTAssertEqual($0, 42) }) + .disposed(by: disposeBag) + + Observable.error(MyError()) + .catchToEffect{ + switch $0 { + case let .success(val): + return val + case .failure: + return -1 + } + } + .subscribe(onNext: { XCTAssertEqual($0, -1) }) + .disposed(by: disposeBag) + } internal func testConcatenate() { var values: [Int] = [] @@ -173,4 +228,157 @@ internal final class EffectTests: XCTestCase { XCTAssertEqual(result, 42) } + +#if DEBUG + func testUnimplemented() { + let disposeBag = DisposeBag() + let effect = Effect.unimplemented("unimplemented") + XCTExpectFailure { + effect + .subscribe() + .disposed(by: disposeBag) + } issueMatcher: { issue in + issue.compactDescription == "unimplemented - An unimplemented effect ran." + } + } +#endif + /// Can't test `.values` +// func testTask() async { +// guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else { return } +// let effect = EffectTask.task { 42 } +// for await result in effect.values { +// XCTAssertEqual(result, 42) +// } +// } + + func testCancellingTask_Infallible() { + @Sendable func work() async -> Int { + do { + try await Task.sleep(nanoseconds: NSEC_PER_MSEC) + XCTFail() + } catch { + } + return 42 + } + var disposeBag = DisposeBag() + + Effect.task { await work() } + .subscribe( + onNext: { _ in + XCTFail() + }, + onCompleted: { + XCTFail() + } + ) + .disposed(by: disposeBag) + disposeBag = DisposeBag() + + + _ = XCTWaiter.wait(for: [.init()], timeout: 1.1) + } + + func testDependenciesTransferredToEffects_Task() async { + struct Feature: ReducerProtocol { + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date) var date + func reduce(into state: inout Int, action: Action) -> Effect { + switch action { + case .tap: + return .task { + .response(Int(self.date.now.timeIntervalSinceReferenceDate)) + } + case let .response(value): + state = value + return .none + } + } + } + let store = TestStore( + initialState: 0, + reducer: Feature() + .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) + ) + + await store.send(.tap).finish(timeout: NSEC_PER_SEC) + await store.receive(.response(1_234_567_890)) { + $0 = 1_234_567_890 + } + } + func testDependenciesTransferredToEffects_Run() async { + struct Feature: ReducerProtocol { + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date) var date + func reduce(into state: inout Int, action: Action) -> Effect { + switch action { + case .tap: + return .run { send in + await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate))) + } + case let .response(value): + state = value + return .none + } + } + } + let store = TestStore( + initialState: 0, + reducer: Feature() + .dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890))) + ) + + await store.send(.tap).finish(timeout: NSEC_PER_SEC) + await store.receive(.response(1_234_567_890)) { + $0 = 1_234_567_890 + } + } + + func testMap() async { + @Dependency(\.date) var date + let effect = + DependencyValues + .withValue(\.date, .init { Date(timeIntervalSince1970: 1_234_567_890) }) { + Effect(value: ()) + .map { date() } + } + let disposeBag = DisposeBag() + var output: Date? + effect + .subscribe(onNext: { + output = $0 + }) + .disposed(by: disposeBag) + XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) + /// TODO: how to use `.values` in the RxSwift to get async version? [Rxswift 6?] +// if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { +// let effect = +// DependencyValues +// .withValue(\.date, .init { Date(timeIntervalSince1970: 1_234_567_890) }) { +// Effect.task {} +// .map { date() } +// } +// output = await effect.values.first(where: { _ in true }) +// XCTAssertEqual(output, Date(timeIntervalSince1970: 1_234_567_890)) +// } + } + + func testCanary1() async { + for _ in 1...100 { + let task = TestStoreTask(rawValue: Task {}, timeout: NSEC_PER_SEC) + await task.finish() + } + } + + func testCanary2() async { + for _ in 1...100 { + let task = TestStoreTask(rawValue: nil, timeout: NSEC_PER_SEC) + await task.finish() + } + } } diff --git a/Tests/RxComposableArchitectureTests/ForEachReducerTests.swift b/Tests/RxComposableArchitectureTests/ForEachReducerTests.swift new file mode 100644 index 0000000..27e3c7b --- /dev/null +++ b/Tests/RxComposableArchitectureTests/ForEachReducerTests.swift @@ -0,0 +1,115 @@ +import RxComposableArchitecture +import XCTest + +@MainActor +final class ForEachReducerTests: XCTestCase { + func testElementAction() async { + let store = TestStore( + initialState: Elements.State( + rows: [ + .init(id: 1, value: "Blob"), + .init(id: 2, value: "Blob Jr."), + .init(id: 3, value: "Blob Sr."), + ] + ), + reducer: Elements() + ) + + await store.send(.row(id: 1, action: "Blob Esq.")) { + $0.rows[id: 1]?.value = "Blob Esq." + } + await store.send(.row(id: 2, action: "")) { + $0.rows[id: 2]?.value = "" + } + await store.receive(.row(id: 2, action: "Empty")) { + $0.rows[id: 2]?.value = "Empty" + } + } + + func testNonElementAction() async { + let store = TestStore( + initialState: Elements.State(), + reducer: Elements() + ) + + await store.send(.buttonTapped) + } + + #if DEBUG + func testMissingElement() async { + let store = TestStore( + initialState: Elements.State(), + reducer: EmptyReducer() + .forEach(\.rows, action: /Elements.Action.row) {} + ) + + XCTExpectFailure { + $0.compactDescription == """ + A "forEach" at "\(#fileID):\(#line - 5)" received an action for a missing element. + + Action: + Elements.Action.row(id:, action:) + + This is generally considered an application logic error, and can happen for a few reasons: + + • A parent reducer removed an element with this ID before this reducer ran. This reducer \ + must run before any other reducer removes an element, which ensures that element reducers \ + can handle their actions while their state is still available. + + • An in-flight effect emitted this action when state contained no element at this ID. \ + While it may be perfectly reasonable to ignore this action, consider canceling the \ + associated effect before an element is removed, especially if it is a long-living effect. + + • This action was sent to the store while its state contained no element at this ID. To \ + fix this make sure that actions for this reducer can only be sent from a view store when \ + its state contains an element at this id. In SwiftUI applications, use "ForEachStore". + """ + } + + await store.send(.row(id: 1, action: "Blob Esq.")) + } + #endif +} + +struct Elements: ReducerProtocol { + struct State: Equatable { + struct Row: Equatable, HashDiffable { + var id: Int + var value: String + } + var rows: IdentifiedArrayOf = [] + } + enum Action: Equatable { + case buttonTapped + case row(id: Int, action: String) + } + #if swift(>=5.7) + var body: some ReducerProtocol { + Reduce { state, action in + .none + } + .forEach(\.rows, action: /Action.row) { + Reduce { state, action in + state.value = action + return action.isEmpty + ? .run { await $0("Empty") } + : .none + } + } + } + #else + var body: Reduce { + Reduce { state, action in + .none + } + .forEach(\.rows, action: /Action.row) { + Reduce { state, action in + state.value = action + return action.isEmpty + ? .run { await $0("Empty") } + : .none + } + } + } + #endif +} diff --git a/Tests/RxComposableArchitectureTests/IfCaseLetReducerTests.swift b/Tests/RxComposableArchitectureTests/IfCaseLetReducerTests.swift new file mode 100644 index 0000000..1d932bd --- /dev/null +++ b/Tests/RxComposableArchitectureTests/IfCaseLetReducerTests.swift @@ -0,0 +1,73 @@ +import RxComposableArchitecture +import XCTest + +@MainActor +final class IfCaseLetReducerTests: XCTestCase { + func testChildAction() async { + struct SomeError: Error, Equatable {} + + let store = TestStore( + initialState: Result.success(0), + reducer: Reduce, Result> { state, action in + .none + } + .ifCaseLet(/Result.success, action: /Result.success) { + Reduce { state, action in + state = action + return state < 0 ? .run { await $0(0) } : .none + } + } + ) + + await store.send(.success(1)) { + $0 = .success(1) + } + await store.send(.failure(SomeError())) + await store.send(.success(-1)) { + $0 = .success(-1) + } + await store.receive(.success(0)) { + $0 = .success(0) + } + } + + #if DEBUG + func testNilChild() async { + struct SomeError: Error, Equatable {} + + let store = TestStore( + initialState: Result.failure(SomeError()), + reducer: EmptyReducer, Result>() + .ifCaseLet(/Result.success, action: /Result.success) {} + ) + + XCTExpectFailure { + $0.compactDescription == """ + An "ifCaseLet" at "\(#fileID):\(#line - 5)" received a child action when child state was \ + set to a different case. … + + Action: + Result.success + State: + Result.failure + + This is generally considered an application logic error, and can happen for a few reasons: + + • A parent reducer set "Result" to a different case before this reducer ran. This reducer \ + must run before any other reducer sets child state to a different case. This ensures that \ + child reducers can handle their actions while their state is still available. + + • An in-flight effect emitted this action when child state was unavailable. While it may \ + be perfectly reasonable to ignore this action, consider canceling the associated effect \ + before child state changes to another case, especially if it is a long-living effect. + + • This action was sent to the store while state was another case. Make sure that actions \ + for this reducer can only be sent from a view store when state is set to the appropriate \ + case. In SwiftUI applications, use "SwitchStore". + """ + } + + await store.send(.success(1)) + } + #endif +} diff --git a/Tests/RxComposableArchitectureTests/IfLetReducerTests.swift b/Tests/RxComposableArchitectureTests/IfLetReducerTests.swift new file mode 100644 index 0000000..26fcf37 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/IfLetReducerTests.swift @@ -0,0 +1,41 @@ +import RxComposableArchitecture +import XCTest + +@MainActor +final class IfLetReducerTests: XCTestCase { + #if DEBUG + func testNilChild() async { + let store = TestStore( + initialState: Int?.none, + reducer: EmptyReducer() + .ifLet(\.self, action: /.self) {} + ) + + XCTExpectFailure { + $0.compactDescription == """ + An "ifLet" at "\(#fileID):\(#line - 5)" received a child action when child state was \ + "nil". … + + Action: + () + + This is generally considered an application logic error, and can happen for a few reasons: + + • A parent reducer set child state to "nil" before this reducer ran. This reducer must run \ + before any other reducer sets child state to "nil". This ensures that child reducers can \ + handle their actions while their state is still available. + + • An in-flight effect emitted this action when child state was "nil". While it may be \ + perfectly reasonable to ignore this action, consider canceling the associated effect \ + before child state becomes "nil", especially if it is a long-living effect. + + • This action was sent to the store while state was "nil". Make sure that actions for this \ + reducer can only be sent from a view store when state is non-"nil". In SwiftUI \ + applications, use "IfLetStore". + """ + } + + await store.send(()) + } + #endif +} diff --git a/Tests/RxComposableArchitectureTests/MemoryManagementTests.swift b/Tests/RxComposableArchitectureTests/MemoryManagementTests.swift index e1a9ee8..a60aa03 100644 --- a/Tests/RxComposableArchitectureTests/MemoryManagementTests.swift +++ b/Tests/RxComposableArchitectureTests/MemoryManagementTests.swift @@ -1,17 +1,18 @@ import Foundation -import RxComposableArchitecture import RxSwift import XCTest +import RxComposableArchitecture + internal final class MemoryManagementTests: XCTestCase { internal func testOwnership_ScopeHoldsOntoParent() { let disposeBag = DisposeBag() - let counterReducer = Reducer { state, _, _ in + let counterReducer = Reduce { state, _ in state += 1 return .none } - let store = Store(initialState: 0, reducer: counterReducer, environment: ()) + let store = Store(initialState: 0, reducer: counterReducer) .scope(state: { "\($0)" }) .scope(state: { Int($0)! }) @@ -19,8 +20,56 @@ internal final class MemoryManagementTests: XCTestCase { store.subscribe { $0 }.subscribe(onNext: { count = $0 }).disposed(by: disposeBag) + XCTAssertEqual(count, 0) + _ = store.send(()) + XCTAssertEqual(count, 1) + } + + internal func testOwnership_ViewStoreHoldsOntoStore() { + let disposeBag = DisposeBag() + let counterReducer = Reduce { state, _ in + state += 1 + return .none + } + let store = Store(initialState: 0, reducer: counterReducer) + + var count = 0 + store.subscribe { $0 }.subscribe(onNext: { count = $0 }).disposed(by: disposeBag) + XCTAssertEqual(count, 0) store.send(()) XCTAssertEqual(count, 1) } + + func testEffectWithMultipleScopes() { + let disposeBag = DisposeBag() + let expectation = self.expectation(description: "") + + enum Action { case tap, response } + let store = Store( + initialState: false, + reducer: Reduce { state, action in + switch action { + case .tap: + state = false + return .task { .response } + case .response: + state = true + return .fireAndForget { + expectation.fulfill() + } + } + } + ) + + var values: [Bool] = [] + store.subscribe() + .subscribe(onNext: { values.append($0) }) + .disposed(by: disposeBag) + + XCTAssertEqual(values, [false]) + store.send(.tap) + self.wait(for: [expectation], timeout: 1) + XCTAssertEqual(values, [false, true]) + } } diff --git a/Tests/RxComposableArchitectureTests/NeverEqualTests.swift b/Tests/RxComposableArchitectureTests/NeverEqualTests.swift index 3713052..b0e6345 100644 --- a/Tests/RxComposableArchitectureTests/NeverEqualTests.swift +++ b/Tests/RxComposableArchitectureTests/NeverEqualTests.swift @@ -5,10 +5,11 @@ // Created by jefferson.setiawan on 03/06/22. // -import RxComposableArchitecture import RxSwift import XCTest +@_spi(Internals) import RxComposableArchitecture + internal final class NeverEqualTests: XCTestCase { private let disposeBag = DisposeBag() @@ -36,16 +37,17 @@ internal final class NeverEqualTests: XCTestCase { case tap } + let reducer = Reduce { state, action in + switch action { + case .tap: + state.run = Stateless() + return .none + } + } + let store = Store( initialState: MyState(), - reducer: Reducer { state, action, _ in - switch action { - case .tap: - state.run = Stateless() - return .none - } - }, - environment: () + reducer: reducer ) var called = 0 store.subscribeNeverEqual(\.$run) diff --git a/Tests/RxComposableArchitectureTests/ReducerBuilderTests.swift b/Tests/RxComposableArchitectureTests/ReducerBuilderTests.swift new file mode 100644 index 0000000..222a897 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/ReducerBuilderTests.swift @@ -0,0 +1,290 @@ +// NB: This file contains compile-time tests to ensure reducer builder generic inference is working. + +import RxComposableArchitecture +import XCTest + +private struct Test: ReducerProtocol { + struct State {} + enum Action { case tap } + + func reduce(into state: inout State, action: Action) -> Effect { + .none + } + + @available(iOS, introduced: 9999.0) + struct Unavailable: ReducerProtocol { + func reduce(into state: inout State, action: Action) -> Effect { + .none + } + } +} + +func testLimitedAvailability() { + _ = CombineReducers { + Test() + if #available(iOS 9999.0, *) { + Test.Unavailable() + } else if #available(iOS 8888.0, *) { + EmptyReducer() + } + } +} + +private struct Root: ReducerProtocol { + struct State { + var feature: Feature.State + var optionalFeature: Feature.State? + var enumFeature: Features.State? + var features: IdentifiedArrayOf + } + + enum Action { + case feature(Feature.Action) + case optionalFeature(Feature.Action) + case enumFeature(Features.Action) + case features(id: Feature.State.IdentifierType, feature: Feature.Action) + } + + @available(iOS, introduced: 9999.0) + struct Unavailable: ReducerProtocol { + let body = EmptyReducer() + } + + #if swift(>=5.7) + var body: some ReducerProtocol { + CombineReducers { + Scope(state: \.feature, action: /Action.feature) { + Feature() + Feature() + } + Scope(state: \.feature, action: /Action.feature) { + Feature() + Feature() + } + } + .ifLet(\.optionalFeature, action: /Action.optionalFeature) { + Feature() + Feature() + } + .ifLet(\.enumFeature, action: /Action.enumFeature) { + EmptyReducer() + .ifCaseLet(/Features.State.featureA, action: /Features.Action.featureA) { + Feature() + Feature() + } + .ifCaseLet(/Features.State.featureB, action: /Features.Action.featureB) { + Feature() + Feature() + } + + Features() + } + .forEach(\.features, action: /Action.features) { + Feature() + Feature() + } + } + + @ReducerBuilder + var testFlowControl: some ReducerProtocol { + if true { + Self() + } + + if Bool.random() { + Self() + } else { + EmptyReducer() + } + + for _ in 1...10 { + Self() + } + + if #available(iOS 9999.0, *) { + Unavailable() + } + } + #else + var body: Reduce { + self.core + .ifLet(\.optionalFeature, action: /Action.optionalFeature) { + Feature() + Feature() + } + .ifLet(\.enumFeature, action: /Action.enumFeature) { + EmptyReducer() + .ifCaseLet(/Features.State.featureA, action: /Features.Action.featureA) { + Feature() + } + .ifCaseLet(/Features.State.featureB, action: /Features.Action.featureB) { + Feature() + } + + Features() + } + .forEach(\.features, action: /Action.features) { + Feature() + Feature() + } + } + + @ReducerBuilder + var core: Reduce { + CombineReducers { + Scope(state: \.feature, action: /Action.feature) { + Feature() + Feature() + } + Scope(state: \.feature, action: /Action.feature) { + Feature() + Feature() + } + } + } + + @ReducerBuilder + var testFlowControl: Reduce { + if true { + Self() + } + + if Bool.random() { + Self() + } else { + EmptyReducer() + } + + for _ in 1...10 { + Self() + } + + if #available(iOS 9999.0, *) { + Unavailable() + } + } + #endif + + struct Feature: ReducerProtocol { + struct State: Equatable, HashDiffable { + let id: Int + } + enum Action { + case action + } + + func reduce(into state: inout State, action: Action) -> Effect { + .none + } + } + + struct Features: ReducerProtocol { + enum State { + case featureA(Feature.State) + case featureB(Feature.State) + } + + enum Action { + case featureA(Feature.Action) + case featureB(Feature.Action) + } + + #if swift(>=5.7) + var body: some ReducerProtocol { + Scope(state: /State.featureA, action: /Action.featureA) { + Feature() + } + Scope(state: /State.featureB, action: /Action.featureB) { + Feature() + } + } + #else + var body: Reduce { + Scope(state: /State.featureA, action: /Action.featureA) { + Feature() + } + Scope(state: /State.featureB, action: /Action.featureB) { + Feature() + } + } + #endif + } +} + +private struct IfLetExample: ReducerProtocol { + struct State { + var optional: Int? + } + + enum Action {} + + #if swift(>=5.7) + var body: some ReducerProtocol { + EmptyReducer().ifLet(\.optional, action: .self) { EmptyReducer() } + } + #else + var body: Reduce { + EmptyReducer().ifLet(\.optional, action: .self) { EmptyReducer() } + } + #endif +} + +private struct IfCaseLetExample: ReducerProtocol { + enum State { + case value(Int) + } + + enum Action {} + + #if swift(>=5.7) + var body: some ReducerProtocol { + EmptyReducer().ifCaseLet(/State.value, action: .self) { EmptyReducer() } + } + #else + var body: Reduce { + EmptyReducer().ifCaseLet(/State.value, action: .self) { EmptyReducer() } + } + #endif +} + +private struct ForEachExample: ReducerProtocol { + struct Element: Equatable, HashDiffable { let id: Int } + + struct State { + var values: IdentifiedArrayOf + } + + enum Action { + case value(id: Element.IdentifierType, action: Never) + } + + #if swift(>=5.7) + var body: some ReducerProtocol { + EmptyReducer().forEach(\.values, action: /Action.value) { EmptyReducer() } + } + #else + var body: Reduce { + EmptyReducer().forEach(\.values, action: /Action.value) { EmptyReducer() } + } + #endif +} + +private struct ScopeIfLetExample: ReducerProtocol { + struct State { + var optionalSelf: Self? { + get { self } + set { newValue.map { self = $0 } } + } + } + + enum Action {} + + var body: some ReducerProtocol { + Scope(state: \.self, action: .self) { + EmptyReducer() + .ifLet(\.optionalSelf, action: .self) { + EmptyReducer() + } + } + } +} diff --git a/Tests/RxComposableArchitectureTests/ReducerTests.swift b/Tests/RxComposableArchitectureTests/ReducerTests.swift index 3e56741..9f71bdd 100644 --- a/Tests/RxComposableArchitectureTests/ReducerTests.swift +++ b/Tests/RxComposableArchitectureTests/ReducerTests.swift @@ -1,105 +1,63 @@ import Foundation import RxComposableArchitecture import RxSwift -//import TestSupport import XCTest +@MainActor internal final class ReducerTests: XCTestCase { internal func testCallableAsFunction() { - let reducer = Reducer { state, _, _ in + let reducer = Reduce { state, _ in state += 1 return .none } var state = 0 - _ = reducer.run(&state, (), ()) + _ = reducer.reduce(into: &state, action: ()) XCTAssertEqual(state, 1) } - internal func testCombine_EffectsAreMerged() { - typealias Scheduler = TestScheduler + func testCombine() async { enum Action: Equatable { case increment } - - var fastValue: Int? - let fastReducer = Reducer { state, _, scheduler in - state += 1 - return Effect.fireAndForget { fastValue = 42 } - .delay(.seconds(1), scheduler: scheduler) - .eraseToEffect() - } - - var slowValue: Int? - let slowReducer = Reducer { state, _, scheduler in - state += 1 - return Effect.fireAndForget { slowValue = 1729 } - .delay(.seconds(2), scheduler: scheduler) - .eraseToEffect() + + struct One: ReducerProtocol { + typealias State = Int + let effect: @Sendable () async -> Void + func reduce(into state: inout State, action: Action) -> Effect { + state += 1 + return .fireAndForget { + await self.effect() + } + } } - - let scheduler = TestScheduler(initialClock: 0) + + var first = false + var second = false + let store = TestStore( initialState: 0, - reducer: .combine(fastReducer, slowReducer), - environment: scheduler, - useNewScope: true + reducer: CombineReducers { + One(effect: { @MainActor in first = true }) + One(effect: { @MainActor in second = true }) + } ) - - store.send(.increment) { - $0 = 2 - } - // Waiting a second causes the fast effect to fire. - scheduler.advance(by: .seconds(1)) - XCTAssertEqual(fastValue, 42) - // Waiting one more second causes the slow effect to fire. This proves that the effects - // are merged together, as opposed to concatenated. - scheduler.advance(by: .seconds(1)) - XCTAssertEqual(fastValue, 42) - XCTAssertEqual(slowValue, 1729) - } - - internal func testCombine() { - enum Action: Equatable { - case increment - } - - var childEffectExecuted = false - let childReducer = Reducer { state, _, _ in - state += 1 - return Effect.fireAndForget { childEffectExecuted = true } - } - - var mainEffectExecuted = false - let mainReducer = Reducer { state, _, _ in - state += 1 - return Effect.fireAndForget { mainEffectExecuted = true } - } - .combined(with: childReducer) - - let store = TestStore( - initialState: 0, - reducer: mainReducer, - environment: (), - useNewScope: true - ) - - store.send(.increment) { - $0 = 2 - } - - XCTAssertTrue(childEffectExecuted) - XCTAssertTrue(mainEffectExecuted) + + await store + .send(.increment) { $0 = 2 } + .finish() + + XCTAssertTrue(first) + XCTAssertTrue(second) } internal func testDefaultSignpost() { let disposeBag = DisposeBag() - - let reducer = Reducer.empty.signpost(log: .default) + + let reducer = EmptyReducer().signpost(log: .default) var n = 0 - // swiftformat:disable:next redundantParens - let effect = reducer.run(&n, (), ()) + let effect = reducer.reduce(into: &n, action: ()) let expectation = self.expectation(description: "effect") effect .subscribe(onCompleted: { expectation.fulfill() }) @@ -110,11 +68,10 @@ internal final class ReducerTests: XCTestCase { internal func testDisabledSignpost() { let disposeBag = DisposeBag() - let reducer = Reducer.empty.signpost(log: .disabled) + let reducer = EmptyReducer().signpost(log: .disabled) var n = 0 - // swiftformat:disable:next redundantParens - let effect = reducer.run(&n, (), ()) + let effect = reducer.reduce(into: &n, action: ()) let expectation = self.expectation(description: "effect") effect .subscribe(onCompleted: { expectation.fulfill() }) diff --git a/Tests/RxComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/RxComposableArchitectureTests/RuntimeWarningTests.swift new file mode 100644 index 0000000..3e0707a --- /dev/null +++ b/Tests/RxComposableArchitectureTests/RuntimeWarningTests.swift @@ -0,0 +1,83 @@ +#if DEBUG +import RxComposableArchitecture +import RxSwift +import XCTest + +final class RuntimeWarningTests: XCTestCase { + func testStoreCreationMainThread() { + XCTExpectFailure { + $0.compactDescription == """ + A store initialized on a non-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. + """ + } + + Task { + _ = Store(initialState: 0, reducer: EmptyReducer()) + } + _ = XCTWaiter.wait(for: [.init()], timeout: 0.5) + } + + func testEffectFinishedMainThread() { + XCTExpectFailure { + $0.compactDescription == """ + An effect completed on a non-main thread. … + + Effect returned from: + RuntimeWarningTests.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. + """ + } + + enum Action { case tap, response } + let store = Store( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .tap: + return Observable.empty() + .subscribeOn(ConcurrentDispatchQueueScheduler(qos: .background)) + .eraseToEffect() + case .response: + return .none + } + } + ) + store.send(.tap) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.5) + } + + func testStoreScopeMainThread() { + XCTExpectFailure { + [ + """ + "Store.scope" was called on a non-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. + """, + """ + A store initialized on a non-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) + } + + let store = Store(initialState: 0, reducer: EmptyReducer()) + Task { + _ = store.scope(state: { $0 }) + } + _ = XCTWaiter.wait(for: [.init()], timeout: 0.5) + } +} +#endif diff --git a/Tests/RxComposableArchitectureTests/RxComposableArchitectureTests.swift b/Tests/RxComposableArchitectureTests/RxComposableArchitectureTests.swift index 1c022d7..b860073 100644 --- a/Tests/RxComposableArchitectureTests/RxComposableArchitectureTests.swift +++ b/Tests/RxComposableArchitectureTests/RxComposableArchitectureTests.swift @@ -18,7 +18,7 @@ internal class RxComposableArchitectureTests: XCTestCase { case squareNow } - let counterReducer = Reducer { + let counterReducer = AnyReducer { state, action, scheduler in switch action { case .incrAndSquareLater: @@ -73,7 +73,7 @@ internal class RxComposableArchitectureTests: XCTestCase { enum Action { case end, incr, start } - let reducer = Reducer { state, action, environment in + let reducer = AnyReducer { state, action, environment in switch action { case .end: return environment.stopEffect.fireAndForget() @@ -116,7 +116,7 @@ internal class RxComposableArchitectureTests: XCTestCase { let mainQueue: TestScheduler } - let reducer = Reducer { state, action, environment in + let reducer = AnyReducer { state, action, environment in enum CancelId {} switch action { diff --git a/Tests/RxComposableArchitectureTests/ScopeTests.swift b/Tests/RxComposableArchitectureTests/ScopeTests.swift new file mode 100644 index 0000000..4f60e41 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/ScopeTests.swift @@ -0,0 +1,183 @@ +import RxComposableArchitecture +import XCTest + +@MainActor +final class ScopeTests: XCTestCase { + func testStructChild() async { + let store = TestStore( + initialState: Feature.State(), + reducer: Feature() + ) + + await store.send(.child1(.incrementButtonTapped)) { + $0.child1.count = 1 + } + await store.send(.child1(.decrementButtonTapped)) { + $0.child1.count = 0 + } + await store.send(.child1(.decrementButtonTapped)) { + $0.child1.count = -1 + } + await store.receive(.child1(.incrementButtonTapped)) { + $0.child1.count = 0 + } + } + + func testEnumChild() async { + let store = TestStore( + initialState: Feature.State(), + reducer: Feature() + ) + + await store.send(.child2(.count(1))) { + $0.child2 = .count(1) + } + await store.send(.child2(.count(-1))) { + $0.child2 = .count(-1) + } + await store.receive(.child2(.count(0))) { + $0.child2 = .count(0) + } + } + + #if DEBUG + func testNilChild() async { + let store = TestStore( + initialState: Child2.State.count(0), + reducer: Scope(state: /Child2.State.name, action: /Child2.Action.name) {} + ) + + XCTExpectFailure { + $0.compactDescription == """ + A "Scope" at "\(#fileID):\(#line - 5)" received a child action when child state was set to \ + a different case. … + + Action: + Child2.Action.name + State: + Child2.State.count + + This is generally considered an application logic error, and can happen for a few reasons: + + • A parent reducer set "Child2.State" to a different case before the scoped reducer ran. \ + Child reducers must run before any parent reducer sets child state to a different case. \ + This ensures that child reducers can handle their actions while their state is still \ + available. Consider using "ReducerProtocol.ifCaseLet" to embed this child reducer in the \ + parent reducer that change its state to ensure the child reducer runs first. + + • An in-flight effect emitted this action when child state was unavailable. While it may \ + be perfectly reasonable to ignore this action, consider canceling the associated effect \ + before child state changes to another case, especially if it is a long-living effect. + + • This action was sent to the store while state was another case. Make sure that actions \ + for this reducer can only be sent from a view store when state is set to the appropriate \ + case. In SwiftUI applications, use "SwitchStore". + """ + } + + await store.send(.name("Blob")) + } + #endif +} + +private struct Feature: ReducerProtocol { + struct State: Equatable { + var child1 = Child1.State() + var child2 = Child2.State.count(0) + } + enum Action: Equatable { + case child1(Child1.Action) + case child2(Child2.Action) + } + #if swift(>=5.7) + var body: some ReducerProtocol { + Scope(state: \.child1, action: /Action.child1) { + Child1() + } + Scope(state: \.child2, action: /Action.child2) { + Child2() + } + } + #else + var body: Reduce { + Scope(state: \.child1, action: /Action.child1) { + Child1() + } + Scope(state: \.child2, action: /Action.child2) { + Child2() + } + } + #endif +} + +private struct Child1: ReducerProtocol { + struct State: Equatable { + var count = 0 + } + enum Action: Equatable { + case decrementButtonTapped + case incrementButtonTapped + } + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .decrementButtonTapped: + state.count -= 1 + return state.count < 0 + ? .run { await $0(.incrementButtonTapped) } + : .none + case .incrementButtonTapped: + state.count += 1 + return .none + } + } +} + +private struct Child2: ReducerProtocol { + enum State: Equatable { + case count(Int) + case name(String) + } + enum Action: Equatable { + case count(Int) + case name(String) + } + #if swift(>=5.7) + var body: some ReducerProtocol { + Scope(state: /State.count, action: /Action.count) { + Reduce { state, action in + state = action + return state < 0 + ? .run { await $0(0) } + : .none + } + } + Scope(state: /State.name, action: /Action.name) { + Reduce { state, action in + state = action + return state.isEmpty + ? .run { await $0("Empty") } + : .none + } + } + } + #else + var body: Reduce { + Scope(state: /State.count, action: /Action.count) { + Reduce { state, action in + state = action + return state < 0 + ? .run { await $0(0) } + : .none + } + } + Scope(state: /State.name, action: /Action.name) { + Reduce { state, action in + state = action + return state.isEmpty + ? .run { await $0("Empty") } + : .none + } + } + } + #endif +} diff --git a/Tests/RxComposableArchitectureTests/StoreOldScopeTest.swift b/Tests/RxComposableArchitectureTests/StoreOldScopeTest.swift new file mode 100644 index 0000000..c20dced --- /dev/null +++ b/Tests/RxComposableArchitectureTests/StoreOldScopeTest.swift @@ -0,0 +1,552 @@ +// +// StoreOldScopeTest.swift +// +// +// Created by andhika.setiadi on 14/11/22. +// + +import RxSwift +import XCTest + +@testable import RxComposableArchitecture + +/// All Test cases in here using `useNewScope: false` on both Store(...) and TestStore(...) +/// +internal final class StoreOldScopeTest: XCTestCase { + private let disposeBag = DisposeBag() + + internal func testCancellableIsRemovedOnImmediatelyCompletingEffect() { + let store = Store( + initialState: (), + reducer: EmptyReducer(), + useNewScope: false + ) + + XCTAssertEqual(store.effectDisposables.count, 0) + + _ = store.send(()) + + XCTAssertEqual(store.effectDisposables.count, 0) + } + + internal func testCancellableIsRemovedWhenEffectCompletes() { + let scheduler = TestScheduler(initialClock: 0) + let effect = Effect(value: ()) + .delay(.seconds(1), scheduler: scheduler) + .eraseToEffect() + + enum Action { case start, end } + + let reducer = Reduce({ _, action in + switch action { + case .start: + return effect.map { .end } + case .end: + return .none + } + }) + + let store = Store( + initialState: (), + reducer: reducer, + useNewScope: false + ) + + XCTAssertEqual(store.effectDisposables.count, 0) + + _ = store.send(.start) + + XCTAssertEqual(store.effectDisposables.count, 1) + + scheduler.advance(by: .seconds(2)) + + XCTAssertEqual(store.effectDisposables.count, 0) + } + + internal func testScopedStoreReceivesUpdatesFromParent() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) + + let parentStore = Store( + initialState: 0, + reducer: counterReducer, + useNewScope: false + ) + let childStore = parentStore.scope(state: String.init) + + var values: [String] = [] + childStore.subscribe { $0 } + .subscribe(onNext: { values.append($0) }) + .disposed(by: disposeBag) + + XCTAssertEqual(values, ["0"]) + + _ = parentStore.send(()) + + XCTAssertEqual(values, ["0", "1"]) + } + + internal func testParentStoreReceivesUpdatesFromChild() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) + + let parentStore = Store( + initialState: 0, + reducer: counterReducer, + useNewScope: false + ) + let childStore = parentStore.scope(state: String.init) + + var values: [Int] = [] + + parentStore.subscribe { $0 } + .subscribe(onNext: { values.append($0) }) + .disposed(by: disposeBag) + + XCTAssertEqual(values, [0]) + + _ = childStore.send(()) + + XCTAssertEqual(values, [0, 1]) + } + + internal func testScopeCallCount() { + let counterReducer = Reduce({ state, action in + state += 1 + return .none + }) + + var numCalls1 = 0 + _ = Store(initialState: 0, reducer: counterReducer, useNewScope: false) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + + XCTAssertEqual(numCalls1, 2) + } + + internal func testScopeCallCount2() { + let counterReducer = Reduce({ state, _ in + state += 1 + return .none + }) + + var numCalls1 = 0 + var numCalls2 = 0 + var numCalls3 = 0 + + let store = Store(initialState: 0, reducer: counterReducer, useNewScope: false) + .scope(state: { (count: Int) -> Int in + numCalls1 += 1 + return count + }) + .scope(state: { (count: Int) -> Int in + numCalls2 += 1 + return count + }) + .scope(state: { (count: Int) -> Int in + numCalls3 += 1 + return count + }) + + XCTAssertEqual(numCalls1, 2) + XCTAssertEqual(numCalls2, 2) + XCTAssertEqual(numCalls3, 2) + + _ = store.send(()) + + XCTAssertEqual(numCalls1, 4) + XCTAssertEqual(numCalls2, 5) + XCTAssertEqual(numCalls3, 6) + + _ = store.send(()) + + XCTAssertEqual(numCalls1, 6) + XCTAssertEqual(numCalls2, 8) + XCTAssertEqual(numCalls3, 10) + + _ = store.send(()) + + XCTAssertEqual(numCalls1, 8) + XCTAssertEqual(numCalls2, 11) + XCTAssertEqual(numCalls3, 14) + } + + internal func testScopeAtIndexCallCount2() { + struct Item: HashDiffable, Equatable { + var id: Int + var qty: Int + } + enum ItemAction { + case didTap + } + enum Action { + case item(id: Int, action: ItemAction) + } + let itemReducer = Reduce, Action>({ state, action in + switch action { + case let .item(id, .didTap): + state[id: id]!.qty += 1 + } + return .none + }) + + var numCalls1 = 0 + var numCalls2 = 0 + + let mock = (1...3).map { + Item(id: $0, qty: 1) + } + + let store = Store( + initialState: IdentifiedArrayOf(mock), + reducer: itemReducer, + useNewScope: false + ) + .scope(state: { (item: IdentifiedArrayOf) -> IdentifiedArrayOf in + numCalls1 += 1 + return item + }) + .scope(at: 1, action: Action.item)! + .scope(state: { (item: Item) -> Item in + numCalls2 += 1 + return item + }) + + _ = store.send((1, .didTap)) + XCTAssertEqual(numCalls1, 4) + XCTAssertEqual(numCalls2, 4) + XCTAssertEqual(store.state.qty, 2) + + _ = store.send((1, .didTap)) + XCTAssertEqual(numCalls1, 6) + XCTAssertEqual(numCalls2, 6) + XCTAssertEqual(store.state.qty, 3) + } + + internal func testSynchronousEffectsSentAfterSinking() { + enum Action { + case tap + case next1 + case next2 + case end + } + var values: [Int] = [] + let counterReducer = Reduce({ state, action in + switch action { + case .tap: + return .merge( + Effect(value: .next1), + Effect(value: .next2), + .fireAndForget { values.append(1) } + ) + case .next1: + return .merge( + Effect(value: .end), + .fireAndForget { values.append(2) } + ) + case .next2: + return .fireAndForget { values.append(3) } + case .end: + return .fireAndForget { values.append(4) } + } + }) + + let store = Store(initialState: (), reducer: counterReducer, useNewScope: false) + + _ = store.send(.tap) + + XCTAssertEqual(values, [1, 2, 3, 4]) + } + + internal func testLotsOfSynchronousActions() { + enum Action { case incr, noop } + let reducer = Reduce({ state, action in + switch action { + case .incr: + state += 1 + return state >= 10000 ? Effect(value: .noop) : Effect(value: .incr) + case .noop: + return .none + } + }) + + let store = Store(initialState: 0, reducer: reducer, useNewScope: false) + _ = store.send(.incr) + XCTAssertEqual(store.state, 10000) + } + + internal func testIfLetAfterScope() { + struct AppState { + var count: Int? + } + + let appReducer = Reduce({ state, action in + state.count = action + return .none + }) + + let parentStore = Store( + initialState: AppState(), + reducer: appReducer, + useNewScope: false + ) + + // NB: This test needs to hold a strong reference to the emitted stores + var outputs: [Int?] = [] + var stores: [Any] = [] + + parentStore + .scope(state: { $0.count }) + .ifLet( + then: { store in + stores.append(store) + outputs.append(store.state) + }, + else: { + outputs.append(nil) + } + ) + .disposed(by: disposeBag) + + XCTAssertEqual(outputs, [nil]) + + _ = parentStore.send(1) + XCTAssertEqual(outputs, [nil, 1]) + + _ = parentStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil]) + + _ = parentStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1]) + + _ = parentStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) + + _ = parentStore.send(1) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) + + _ = parentStore.send(nil) + XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) + } + + internal func testIfLetTwo() { + let parentStore = Store( + initialState: 0, + reducer: Reduce({ state, action in + if action { + state? += 1 + return .none + } else { + return Observable.just(true) + .observeOn(MainScheduler.instance) + .eraseToEffect() + } + }), + useNewScope: false + ) + + parentStore.ifLet { childStore in + childStore + .observable + .subscribe() + .disposed(by: self.disposeBag) + + _ = childStore.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + _ = childStore.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + _ = childStore.send(false) + _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) + XCTAssertEqual(childStore.state, 3) + } + .disposed(by: disposeBag) + } + + internal func testActionQueuing() { + let subject = PublishSubject() + + enum Action: Equatable { + case incrementTapped + case initialize + case doIncrement + } + + let store = TestStore( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case .incrementTapped: + subject.onNext(()) + return .none + + case .initialize: + return subject.map { .doIncrement }.eraseToEffect() + + case .doIncrement: + state += 1 + return .none + } + }) + ) + store.send(.initialize) + store.send(.incrementTapped) + store.receive(.doIncrement) { + $0 = 1 + } + store.send(.incrementTapped) + store.receive(.doIncrement) { + $0 = 2 + } + subject.onCompleted() + } + + internal func testCoalesceSynchronousActions() { + let store = Store( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case 0: + return .merge( + Effect(value: 1), + Effect(value: 2), + Effect(value: 3) + ) + default: + state = action + return .none + } + }), + useNewScope: false + ) + + var emissions: [Int] = [] + store.subscribe { $0 } + .subscribe { emissions.append($0) } + .disposed(by: disposeBag) + + XCTAssertEqual(emissions, [0]) + + _ = store.send(0) + + XCTAssertEqual(emissions, [0, 1, 2, 3]) + } + + internal func testSyncEffectsFromEnvironment() { + enum Action: Equatable { + // subscribes to a long living effect, potentially feeding data + // back into the store + case onAppear + + // Talks to the environment, eventually feeding data back into the store + case onUserAction + + // External event coming in from the environment, updating state + case externalAction + } + + struct Environment { + var externalEffects = PublishSubject() + } + + let counterReducer = AnyReducer { state, action, env in + switch action { + case .onAppear: + return env.externalEffects.eraseToEffect() + case .onUserAction: + return .fireAndForget { + // This would actually do something async in the environment + // that feeds back eventually via the `externalEffectPublisher` + // Here we send an action sync, which could e.g. happen for an error case, .. + env.externalEffects.onNext(.externalAction) + } + case .externalAction: + state += 1 + } + return .none + } + let parentStore = Store( + initialState: 1, + reducer: counterReducer, + environment: Environment(), + useNewScope: false + ) + + // subscribes to a long living publisher of actions + _ = parentStore.send(.onAppear) + + _ = parentStore.send(.onUserAction) + + // State should be at 2 now + XCTAssertEqual(parentStore.state, 2) + } + + internal func testBufferedActionProcessing() { + struct ChildState: Equatable { + var count: Int? + } + + struct ParentState: Equatable { + var count: Int? + var child: ChildState? + } + + enum ParentAction: Equatable { + case button + case child(Int?) + } + + var handledActions: [ParentAction] = [] + let parentReducer = Reduce({ 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 + } + }) + .ifLet(\.child, action: /ParentAction.child) { + Reduce({ state, action in + state.count = action + return .none + }) + } + + let parentStore = Store( + initialState: ParentState(), + reducer: parentReducer, + useNewScope: false + ) + + parentStore + .scope( + state: \.child, + action: ParentAction.child + ) + .ifLet { childStore in + childStore.send(2) + } + .disposed(by: disposeBag) + + XCTAssertEqual(handledActions, []) + + _ = parentStore.send(.button) + + XCTAssertEqual( + handledActions, + [ + .button, + .child(2), + ]) + } +} diff --git a/Tests/RxComposableArchitectureTests/StoreTests.swift b/Tests/RxComposableArchitectureTests/StoreTests.swift index ea6aa82..5654116 100644 --- a/Tests/RxComposableArchitectureTests/StoreTests.swift +++ b/Tests/RxComposableArchitectureTests/StoreTests.swift @@ -3,262 +3,165 @@ import XCTest @testable import RxComposableArchitecture +/// All Test cases in here using `useNewScope: true` on both Store(...) and TestStore(...) +/// +@MainActor internal final class StoreTests: XCTestCase { private let disposeBag = DisposeBag() - + internal func testCancellableIsRemovedOnImmediatelyCompletingEffect() { - let reducer = Reducer { _, _, _ in .none } - let store = Store(initialState: (), reducer: reducer, environment: ()) - + let store = Store(initialState: (), reducer: EmptyReducer()) + XCTAssertEqual(store.effectDisposables.count, 0) - - store.send(()) - + + _ = store.send(()) + XCTAssertEqual(store.effectDisposables.count, 0) } - + internal func testCancellableIsRemovedWhenEffectCompletes() { let scheduler = TestScheduler(initialClock: 0) let effect = Effect(value: ()) .delay(.seconds(1), scheduler: scheduler) .eraseToEffect() - + enum Action { case start, end } - - let reducer = Reducer { _, action, _ in + + let reducer = Reduce({ _, action in switch action { case .start: return effect.map { .end } case .end: return .none } - } - let store = Store(initialState: (), reducer: reducer, environment: ()) - + }) + + let store = Store(initialState: (), reducer: reducer) + XCTAssertEqual(store.effectDisposables.count, 0) - - store.send(.start) - + + _ = store.send(.start) + XCTAssertEqual(store.effectDisposables.count, 1) - + scheduler.advance(by: .seconds(2)) - + XCTAssertEqual(store.effectDisposables.count, 0) } - + internal func testScopedStoreReceivesUpdatesFromParent() { - let counterReducer = Reducer { state, _, _ in + let counterReducer = Reduce({ state, _ in state += 1 return .none - } - - let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) + }) + + let parentStore = Store(initialState: 0, reducer: counterReducer) let childStore = parentStore.scope(state: String.init) - + var values: [String] = [] childStore.subscribe { $0 } .subscribe(onNext: { values.append($0) }) .disposed(by: disposeBag) - + XCTAssertEqual(values, ["0"]) - - parentStore.send(()) - + + _ = parentStore.send(()) + XCTAssertEqual(values, ["0", "1"]) } - + internal func testParentStoreReceivesUpdatesFromChild() { - let counterReducer = Reducer { state, _, _ in + let counterReducer = Reduce({ state, _ in state += 1 return .none - } - - let parentStore = Store(initialState: 0, reducer: counterReducer, environment: ()) + }) + + let parentStore = Store(initialState: 0, reducer: counterReducer) let childStore = parentStore.scope(state: String.init) - + var values: [Int] = [] - parentStore.subscribe { $0 } .subscribe(onNext: { values.append($0) }) .disposed(by: disposeBag) - + XCTAssertEqual(values, [0]) - - childStore.send(()) - + + _ = childStore.send(()) + XCTAssertEqual(values, [0, 1]) } - - internal func testScopeCallCount() { - let counterReducer = Reducer { state, _, _ in state += 1 - return .none - } - - var numCalls1 = 0 - _ = Store(initialState: 0, reducer: counterReducer, environment: (), useNewScope: false) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - - XCTAssertEqual(numCalls1, 2) - } - internal func testScopeCallCountUsingNewScope() { - let counterReducer = Reducer { state, _, _ in state += 1 - return .none - } - - var numCalls1 = 0 - _ = Store(initialState: 0, reducer: counterReducer, environment: (), useNewScope: true) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - - XCTAssertEqual(numCalls1, 1) - } - - internal func testScopeCallCount2() { - let counterReducer = Reducer { state, _, _ in + internal func testScopeCallCount() { + let counterReducer = Reduce({ state, action in state += 1 return .none - } - + }) + var numCalls1 = 0 - var numCalls2 = 0 - var numCalls3 = 0 - - let store = Store(initialState: 0, reducer: counterReducer, environment: (), useNewScope: false) + _ = Store(initialState: 0, reducer: counterReducer) .scope(state: { (count: Int) -> Int in numCalls1 += 1 return count }) - .scope(state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }) - .scope(state: { (count: Int) -> Int in - numCalls3 += 1 - return count - }) - - XCTAssertEqual(numCalls1, 2) - XCTAssertEqual(numCalls2, 2) - XCTAssertEqual(numCalls3, 2) - - store.send(()) - - XCTAssertEqual(numCalls1, 4) - XCTAssertEqual(numCalls2, 5) - XCTAssertEqual(numCalls3, 6) - - store.send(()) - - XCTAssertEqual(numCalls1, 6) - XCTAssertEqual(numCalls2, 8) - XCTAssertEqual(numCalls3, 10) - - store.send(()) - - XCTAssertEqual(numCalls1, 8) - XCTAssertEqual(numCalls2, 11) - XCTAssertEqual(numCalls3, 14) + + XCTAssertEqual(numCalls1, 1) } - internal func testScopeCallCount2UsingNewScope() { - let counterReducer = Reducer { state, _, _ in + internal func testScopeCallCount2() { + let counterReducer = Reduce({ state, _ in state += 1 return .none - } - + }) + var numCalls1 = 0 var numCalls2 = 0 var numCalls3 = 0 - - let store = Store(initialState: 0, reducer: counterReducer, environment: (), useNewScope: true) - .scope(state: { (count: Int) -> Int in - numCalls1 += 1 - return count - }) - .scope(state: { (count: Int) -> Int in - numCalls2 += 1 - return count - }) - .scope(state: { (count: Int) -> Int in - numCalls3 += 1 - return count - }) - + + let store1 = Store(initialState: 0, reducer: counterReducer) + 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 + }) + XCTAssertEqual(numCalls1, 1) XCTAssertEqual(numCalls2, 1) XCTAssertEqual(numCalls3, 1) - - store.send(()) - + + _ = store4.send(()) + XCTAssertEqual(numCalls1, 2) XCTAssertEqual(numCalls2, 2) XCTAssertEqual(numCalls3, 2) - - store.send(()) - + + _ = store4.send(()) + XCTAssertEqual(numCalls1, 3) XCTAssertEqual(numCalls2, 3) XCTAssertEqual(numCalls3, 3) - - store.send(()) - + + _ = store4.send(()) + XCTAssertEqual(numCalls1, 4) XCTAssertEqual(numCalls2, 4) XCTAssertEqual(numCalls3, 4) - } - - internal func testScopeAtIndexCallCount2() { - struct Item: HashDiffable, Equatable { - var id: Int - var qty: Int - } - enum ItemAction { - case didTap - } - enum Action { - case item(id: Int, action: ItemAction) - } - let itemReducer = Reducer, Action, Void> { state, action, _ in - switch action { - case let .item(id, .didTap): - state[id: id]!.qty += 1 - } - return .none - } - - var numCalls1 = 0 - var numCalls2 = 0 - let mock = (1...3).map { - Item(id: $0, qty: 1) - } - - let store = Store(initialState: IdentifiedArrayOf(mock), reducer: itemReducer, environment: (), useNewScope: false) - .scope(state: { (item: IdentifiedArrayOf) -> IdentifiedArrayOf in - numCalls1 += 1 - return item - }) - .scope(at: 1, action: Action.item)! - .scope(state: { (item: Item) -> Item in - numCalls2 += 1 - return item - }) - - store.send((1, .didTap)) - XCTAssertEqual(numCalls1, 4) + _ = store4.send(()) + + XCTAssertEqual(numCalls1, 5) XCTAssertEqual(numCalls2, 5) - XCTAssertEqual(store.state.qty, 2) + XCTAssertEqual(numCalls3, 5) - store.send((1, .didTap)) - XCTAssertEqual(numCalls1, 6) - XCTAssertEqual(numCalls2, 8) - XCTAssertEqual(store.state.qty, 3) + _ = store1 + _ = store2 + _ = store3 } internal func testScopeAtIndexCallCount2UseNewScope() { @@ -272,43 +175,43 @@ internal final class StoreTests: XCTestCase { enum Action { case item(id: Int, action: ItemAction) } - let itemReducer = Reducer, Action, Void> { state, action, _ in + let itemReducer = Reduce, Action>({ state, action in switch action { case let .item(id, .didTap): state[id: id]!.qty += 1 } return .none - } - + }) + var numCalls1 = 0 var numCalls2 = 0 let mock = (1...3).map { Item(id: $0, qty: 1) } - - let store = Store(initialState: IdentifiedArrayOf(mock), reducer: itemReducer, environment: (), useNewScope: true) + + let store1 = Store(initialState: IdentifiedArrayOf(mock), reducer: itemReducer) .scope(state: { (item: IdentifiedArrayOf) -> IdentifiedArrayOf in numCalls1 += 1 return item }) - .scope(at: 1, action: Action.item)! - .scope(state: { (item: Item) -> Item in + let store2 = store1.scope(at: 1, action: Action.item)! + let store3 = store2.scope(state: { (item: Item) -> Item in numCalls2 += 1 return item }) - - store.send((1, .didTap)) + + _ = store3.send((1, .didTap)) XCTAssertEqual(numCalls1, 2) XCTAssertEqual(numCalls2, 2) - XCTAssertEqual(store.state.qty, 2) + XCTAssertEqual(store3.state.qty, 2) - store.send((1, .didTap)) + _ = store3.send((1, .didTap)) XCTAssertEqual(numCalls1, 3) XCTAssertEqual(numCalls2, 3) - XCTAssertEqual(store.state.qty, 3) + XCTAssertEqual(store3.state.qty, 3) } - + internal func testSynchronousEffectsSentAfterSinking() { enum Action { case tap @@ -317,7 +220,7 @@ internal final class StoreTests: XCTestCase { case end } var values: [Int] = [] - let counterReducer = Reducer { _, action, _ in + let counterReducer = Reduce({ state, action in switch action { case .tap: return .merge( @@ -335,48 +238,51 @@ internal final class StoreTests: XCTestCase { case .end: return .fireAndForget { values.append(4) } } - } - - let store = Store(initialState: (), reducer: counterReducer, environment: ()) - - store.send(.tap) - + }) + + let store = Store( + initialState: (), + reducer: counterReducer + ) + + _ = store.send(.tap) + XCTAssertEqual(values, [1, 2, 3, 4]) } - + internal func testLotsOfSynchronousActions() { enum Action { case incr, noop } - let reducer = Reducer { state, action, _ in + let reducer = Reduce({ state, action in switch action { case .incr: state += 1 - return state >= 10000 ? Effect(value: .noop) : Effect(value: .incr) + return state >= 100_000 ? Effect(value: .noop) : Effect(value: .incr) case .noop: return .none } - } - - let store = Store(initialState: 0, reducer: reducer, environment: ()) - store.send(.incr) - XCTAssertEqual(store.state, 10000) + }) + + let store = Store(initialState: 0, reducer: reducer) + _ = store.send(.incr) + XCTAssertEqual(store.state, 100_000) } - + internal func testIfLetAfterScope() { struct AppState { var count: Int? } - - let appReducer = Reducer { state, action, _ in + + let appReducer = Reduce({ state, action in state.count = action return .none - } - - let parentStore = Store(initialState: AppState(), reducer: appReducer, environment: ()) - + }) + + let parentStore = Store(initialState: AppState(), reducer: appReducer) + // NB: This test needs to hold a strong reference to the emitted stores var outputs: [Int?] = [] var stores: [Any] = [] - + parentStore .scope(state: { $0.count }) .ifLet( @@ -389,32 +295,32 @@ internal final class StoreTests: XCTestCase { } ) .disposed(by: disposeBag) - + XCTAssertEqual(outputs, [nil]) - - parentStore.send(1) + + _ = parentStore.send(1) XCTAssertEqual(outputs, [nil, 1]) - - parentStore.send(nil) + + _ = parentStore.send(nil) XCTAssertEqual(outputs, [nil, 1, nil]) - - parentStore.send(1) + + _ = parentStore.send(1) XCTAssertEqual(outputs, [nil, 1, nil, 1]) - - parentStore.send(nil) + + _ = parentStore.send(nil) XCTAssertEqual(outputs, [nil, 1, nil, 1, nil]) - - parentStore.send(1) + + _ = parentStore.send(1) XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1]) - - parentStore.send(nil) + + _ = parentStore.send(nil) XCTAssertEqual(outputs, [nil, 1, nil, 1, nil, 1, nil]) } - + internal func testIfLetTwo() { let parentStore = Store( initialState: 0, - reducer: Reducer { state, action, _ in + reducer: Reduce({ state, action in if action { state? += 1 return .none @@ -423,53 +329,51 @@ internal final class StoreTests: XCTestCase { .observeOn(MainScheduler.instance) .eraseToEffect() } - }, - environment: () + }) ) - + parentStore.ifLet { childStore in childStore .observable .subscribe() .disposed(by: self.disposeBag) - - childStore.send(false) + + _ = childStore.send(false) _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - childStore.send(false) + _ = childStore.send(false) _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) - childStore.send(false) + _ = childStore.send(false) _ = XCTWaiter.wait(for: [.init()], timeout: 0.1) XCTAssertEqual(childStore.state, 3) } .disposed(by: disposeBag) } - + internal func testActionQueuing() { let subject = PublishSubject() - + enum Action: Equatable { case incrementTapped case initialize case doIncrement } - + let store = TestStore( initialState: 0, - reducer: Reducer { state, action, _ in + reducer: Reduce({ state, action in switch action { case .incrementTapped: subject.onNext(()) return .none - + case .initialize: return subject.map { .doIncrement }.eraseToEffect() - + case .doIncrement: state += 1 return .none } - }, - environment: (), + }), useNewScope: true ) store.send(.initialize) @@ -487,7 +391,7 @@ internal final class StoreTests: XCTestCase { internal func testCoalesceSynchronousActions() { let store = Store( initialState: 0, - reducer: Reducer { state, action, _ in + reducer: Reduce({ state, action in switch action { case 0: return .merge( @@ -499,9 +403,7 @@ internal final class StoreTests: XCTestCase { state = action return .none } - }, - environment: (), - useNewScope: false + }) ) var emissions: [Int] = [] @@ -511,84 +413,248 @@ internal final class StoreTests: XCTestCase { XCTAssertEqual(emissions, [0]) - store.send(0) + _ = store.send(0) - XCTAssertEqual(emissions, [0, 1, 2, 3]) + XCTAssertEqual(emissions, [0, 3]) } - internal func testCoalesceSynchronousActionsUsingNewScope() { - 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: (), - useNewScope: true + internal func testSyncEffectsFromEnvironment() { + let parentStore = Store( + initialState: CounterFeature.State(counter: 1), + reducer: CounterFeature() + .dependency(\.context, .test) ) - var emissions: [Int] = [] - store.subscribe { $0 } - .subscribe { emissions.append($0) } - .disposed(by: disposeBag) - - XCTAssertEqual(emissions, [0]) + // subscribes to a long living publisher of actions + _ = parentStore.send(.onAppear) - store.send(0) + _ = parentStore.send(.onUserAction) - XCTAssertEqual(emissions, [0, 3]) + // State should be at 2 now + XCTAssertEqual(parentStore.state.counter, 2) } - internal func testSyncEffectsFromEnvironment() { - enum Action: Equatable { - // subscribes to a long living effect, potentially feeding data - // back into the store - case onAppear - - // Talks to the environment, eventually feeding data back into the store - case onUserAction - - // External event coming in from the environment, updating state - case externalAction + internal func testBufferedActionProcessing() { + struct ChildState: Equatable { + var count: Int? } - - struct Environment { - var externalEffects = PublishSubject() + + struct ParentState: Equatable { + var count: Int? + var child: ChildState? + } + + enum ParentAction: Equatable { + case button + case child(Int?) + } + + var handledActions: [ParentAction] = [] + let parentReducer = Reduce({ 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 + } + }) + .ifLet(\.child, action: /ParentAction.child) { + Reduce({ state, action in + state.count = action + return .none + }) } + + let parentStore = Store( + initialState: ParentState(), + reducer: parentReducer + ) + + parentStore + .scope( + state: \.child, + action: ParentAction.child + ) + .ifLet { childStore in + childStore.send(2) + } + .disposed(by: disposeBag) + + XCTAssertEqual(handledActions, []) + + _ = parentStore.send(.button) - let counterReducer = Reducer { state, action, env in + XCTAssertEqual( + handledActions, + [ + .button, + .child(2), + ]) + } + + internal func testCascadingTaskCancellation() async { + enum Action { case task, response, response1, response2 } + + let reducer = Reduce({ state, action in switch action { - case .onAppear: - return env.externalEffects.eraseToEffect() - case .onUserAction: - return .fireAndForget { - // This would actually do something async in the environment - // that feeds back eventually via the `externalEffectPublisher` - // Here we send an action sync, which could e.g. happen for an error case, .. - env.externalEffects.onNext(.externalAction) + case .task: + return .task { .response } + case .response: + return .merge( + Observable.never().eraseToEffect(), + .task { .response1 } + ) + case .response1: + return .merge( + Observable.never().eraseToEffect(), + .task { .response2 } + ) + case .response2: + return Observable.never().eraseToEffect() + } + }) + + let store = TestStore( + initialState: 0, + reducer: reducer, + useNewScope: true + ) + + let task = await store.send(Action.task) + await store.receive(Action.response) + await store.receive(Action.response1) + await store.receive(Action.response2) + await task.cancel() + } + + internal func testTaskCancellationEmpty() async { + enum Action { case task } + + let store = TestStore( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case .task: + return .fireAndForget { try await Task.never() } } - case .externalAction: - state += 1 + }), + useNewScope: true + ) + + await store.send(.task).cancel() + } + + internal func testScopeCancellation() async throws { + let neverEndingTask = Task { try await Task.never() } + + let store = Store( + initialState: (), + reducer: Reduce({ _, _ in + .fireAndForget { + try await neverEndingTask.value } - return .none + }) + ) + 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) + } + + func testOverrideDependenciesDirectlyOnReducer() { + struct Counter: ReducerProtocol { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession + + func reduce(into state: inout Int, action: Bool) -> Effect { + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none } - let parentStore = Store(initialState: 1, reducer: counterReducer, environment: Environment(), useNewScope: true) - - // subscribes to a long living publisher of actions - parentStore.send(.onAppear) + } + + let store = Store( + initialState: 0, + reducer: Counter() + .dependency(\.calendar, Calendar(identifier: .gregorian)) + .dependency(\.locale, Locale(identifier: "en_US")) + .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) + .dependency(\.urlSession, URLSession(configuration: .ephemeral)) + ) + + store.send(true) + } +} + +/// we use CounterFeature reducer on this file test scope only +/// +fileprivate struct CounterFeature: ReducerProtocol { + fileprivate struct State: Equatable { + internal var counter: Int + } + + fileprivate enum Action: Equatable { + // subscribes to a long living effect, potentially feeding data + // back into the store + case onAppear - parentStore.send(.onUserAction) + // Talks to the environment, eventually feeding data back into the store + case onUserAction - // State should be at 2 now - XCTAssertEqual(parentStore.state, 2) + // External event coming in from the environment, updating state + case externalAction + } + + @Dependency(\.counterEnv) private var env + + fileprivate func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .onAppear: + return env.externalEffects.eraseToEffect() + case .onUserAction: + return .fireAndForget { + // This would actually do something async in the environment + // that feeds back eventually via the `externalEffectPublisher` + // Here we send an action sync, which could e.g. happen for an error case, .. + env.externalEffects.onNext(.externalAction) + } + case .externalAction: + state.counter += 1 + } + return .none } } + +fileprivate struct CounterEnvironment { + fileprivate var externalEffects: PublishSubject +} + +extension CounterEnvironment { + fileprivate static let mock = Self(externalEffects: PublishSubject()) +} + +extension CounterEnvironment: TestDependencyKey { + fileprivate static var testValue: CounterEnvironment { .mock } +} + +extension DependencyValues { + fileprivate var counterEnv: CounterEnvironment { + get { self[CounterEnvironment.self] } + set { self[CounterEnvironment.self] = newValue } + } +} + diff --git a/Tests/RxComposableArchitectureTests/TaskCancellationTests.swift b/Tests/RxComposableArchitectureTests/TaskCancellationTests.swift new file mode 100644 index 0000000..ad4c789 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/TaskCancellationTests.swift @@ -0,0 +1,45 @@ +#if DEBUG +import XCTest +@_spi(Internals) import RxComposableArchitecture + +final class TaskCancellationTests: XCTestCase { + func testCancellation() async throws { + _cancellablesLock.sync { + _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 }) + Task.cancel(id: ID.self) + await Task.megaYield(count: 20) + XCTAssertEqual(_cancellablesLock.sync { _cancellationCancellables }, [:]) + do { + try await task.cancellableValue + XCTFail() + } catch { + } + } + + func testWithTaskCancellationCleansUpTask() async throws { + let task = Task { + try await withTaskCancellation(id: 0) { + try await Task.sleep(nanoseconds: NSEC_PER_SEC * 1000) + } + } + + try await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) + XCTAssertEqual(_cancellationCancellables.count, 1) + + task.cancel() + try await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) + XCTAssertEqual(_cancellationCancellables.count, 0) + } +} +#endif diff --git a/Tests/RxComposableArchitectureTests/TaskResultTests.swift b/Tests/RxComposableArchitectureTests/TaskResultTests.swift new file mode 100644 index 0000000..3cc22f0 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/TaskResultTests.swift @@ -0,0 +1,121 @@ +import RxComposableArchitecture +import XCTest + +final class TaskResultTests: XCTestCase { + #if DEBUG + 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 == """ + "TaskResultTests.Failure" is not equatable. … + + To test two values of this type, it must conform to the "Equatable" protocol. For example: + + extension TaskResultTests.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 == """ + "TaskResultTests.Failure" is not hashable. … + + To hash a value of this type, it must conform to the "Hashable" protocol. For example: + + extension TaskResultTests.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/RxComposableArchitectureTests/TestStoreFailureTests.swift b/Tests/RxComposableArchitectureTests/TestStoreFailureTests.swift new file mode 100644 index 0000000..f8b26f3 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/TestStoreFailureTests.swift @@ -0,0 +1,291 @@ +#if DEBUG + import RxComposableArchitecture + import XCTest + + @MainActor + final class TestStoreFailureTests: XCTestCase { + func testNoStateChangeFailure() { + enum Action { case first, second } + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .first: return .init(value: .second) + case .second: return .none + } + } + ) + + XCTExpectFailure { + _ = store.send(.first) { _ = $0 } + } issueMatcher: { + $0.compactDescription == """ + 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. + """ + } + + XCTExpectFailure { + store.receive(.second) { _ = $0 } + } issueMatcher: { + $0.compactDescription == """ + 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. + """ + } + } + + func testStateChangeFailure() { + struct State: Equatable { var count = 0 } + let store = TestStore( + initialState: .init(), + reducer: Reduce { state, action in + state.count += 1 + return .none + } + ) + + XCTExpectFailure { + _ = store.send(()) { $0.count = 0 } + } issueMatcher: { + $0.compactDescription == """ + A state change does not match expectation: … + + − TestStoreFailureTests.State(count: 0) + + TestStoreFailureTests.State(count: 1) + + (Expected: −, Actual: +) + """ + } + } + + func testUnexpectedStateChangeOnSendFailure() { + struct State: Equatable { var count = 0 } + let store = TestStore( + initialState: .init(), + reducer: Reduce { state, action in state.count += 1 + return .none + } + ) + + _ = XCTExpectFailure { + store.send(()) + } issueMatcher: { + $0.compactDescription == """ + State was not expected to change, but a change occurred: … + + − TestStoreFailureTests.State(count: 0) + + TestStoreFailureTests.State(count: 1) + + (Expected: −, Actual: +) + """ + } + } + + func testUnexpectedStateChangeOnReceiveFailure() { + struct State: Equatable { var count = 0 } + enum Action { case first, second } + let store = TestStore( + initialState: .init(), + reducer: Reduce { state, action in + switch action { + case .first: return .init(value: .second) + case .second: + state.count += 1 + return .none + } + } + ) + + store.send(.first) + XCTExpectFailure { + store.receive(.second) + } issueMatcher: { + $0.compactDescription == """ + State was not expected to change, but a change occurred: … + + − TestStoreFailureTests.State(count: 0) + + TestStoreFailureTests.State(count: 1) + + (Expected: −, Actual: +) + """ + } + } + + func testReceivedActionAfterDeinit() { + XCTExpectFailure { + do { + enum Action { case first, second } + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .first: return .init(value: .second) + case .second: return .none + } + } + ) + store.send(.first) + } + } issueMatcher: { + $0.compactDescription == """ + The store received 1 unexpected action after this one: … + + Unhandled actions: [ + [0]: TestStoreFailureTests.Action.second + ] + """ + } + } + + func testEffectInFlightAfterDeinit() { + XCTExpectFailure { + do { + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + .task { try await Task.sleep(nanoseconds: NSEC_PER_SEC) } + } + ) + store.send(()) + } + } issueMatcher: { + $0.compactDescription == """ + 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 clock/scheduler (via "receive(on:)", "delay", "debounce", etc.), \ + make sure that you wait enough time for it to perform the effect. If you are using a \ + test clock/scheduler, advance it so that the effects may complete, or consider using an \ + immediate clock/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. + """ + } + } + + func testSendActionBeforeReceivingFailure() { + enum Action { case first, second } + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .first: return .init(value: .second) + case .second: return .none + } + } + ) + + XCTExpectFailure { + store.send(.first) + store.send(.first) + store.receive(.second) + store.receive(.second) + } issueMatcher: { issue in + issue.compactDescription == """ + Must handle 1 received action before sending an action: … + + Unhandled actions: [ + [0]: TestStoreFailureTests.Action.second + ] + """ + } + } + + func testReceiveNonExistentActionFailure() { + enum Action { case action } + let store = TestStore( + initialState: 0, + reducer: Reduce { _, _ in .none } + ) + + XCTExpectFailure { + store.receive(.action) + } issueMatcher: { issue in + issue.compactDescription == #"Expected to receive an action "action", but didn't get one."# + } + } + + func testReceiveUnexpectedActionFailure() { + enum Action { case first, second } + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + switch action { + case .first: return .init(value: .second) + case .second: return .none + } + }, + useNewScope: true + ) + + XCTExpectFailure { + store.send(.first) + store.receive(.first) + } issueMatcher: { issue in + issue.compactDescription == """ + Received unexpected action: … + + − TestStoreFailureTests.Action.first + + TestStoreFailureTests.Action.second + + (Expected: −, Received: +) + """ + } + } + + func testModifyClosureThrowsErrorFailure() { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, _ in .none } + ) + + XCTExpectFailure { + _ = store.send(()) { _ in + struct SomeError: Error {} + throw SomeError() + } + } issueMatcher: { issue in + issue.compactDescription == "Threw error: SomeError()" + } + } + + func testExpectedStateEqualityMustModify() async { + let reducer = Reduce { state, action in + switch action { + case true: return Effect(value: false) + case false: return .none + } + } + let store = TestStore(initialState: 0, reducer: reducer) + + await store.send(true) + await store.receive(false) + + XCTExpectFailure { + _ = store.send(true) { + $0 = 0 + } + } + XCTExpectFailure { + store.receive(false) { + $0 = 0 + } + } + } + } +#endif diff --git a/Tests/RxComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/RxComposableArchitectureTests/TestStoreNonExhaustiveTests.swift new file mode 100644 index 0000000..f7b2e15 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -0,0 +1,787 @@ +#if DEBUG +import RxComposableArchitecture +import XCTest + +@MainActor +final class TestStoreNonExhaustiveTests: XCTestCase { + func testSkipReceivedActions_NonStrict() async { + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + if action { + state += 1 + return .init(value: false) + } else { + state += 1 + return .none + } + } + ) + + await store.send(true) { $0 = 1 } + XCTAssertEqual(store.state, 1) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state, 2) + } + + func testSkipReceivedActions_Strict() async { + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + if action { + state += 1 + return .init(value: false) + } else { + state += 1 + return .none + } + } + ) + + await store.send(true) { $0 = 1 } + XCTAssertEqual(store.state, 1) + await store.receive(false) { $0 = 2 } + XCTAssertEqual(store.state, 2) + XCTExpectFailure { + $0.compactDescription == "There were no received actions to skip." + } + await store.skipReceivedActions(strict: true) + } + + func testSkipReceivedActions_NonExhaustive() async { + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + if action { + state += 1 + return .init(value: false) + } else { + state += 1 + return .none + } + } + ) + store.exhaustivity = .off + + await store.send(true) { $0 = 1 } + XCTAssertEqual(store.state, 1) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state, 2) + } + + func testSkipReceivedActions_PartialExhaustive() async { + let store = TestStore( + initialState: 0, + reducer: Reduce { state, action in + if action { + state += 1 + return .init(value: false) + } else { + state += 1 + return .none + } + } + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(true) { $0 = 1 } + XCTAssertEqual(store.state, 1) + await store.skipReceivedActions(strict: false) + XCTAssertEqual(store.state, 2) + } + + func testCancelInFlightEffects_NonStrict() async { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, action in + .run { _ in try await Task.sleep(nanoseconds: NSEC_PER_SEC) } + } + ) + + await store.send(true) + await store.skipInFlightEffects(strict: false) + } + + func testCancelInFlightEffects_Strict() async { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, action in + .run { _ in try await Task.sleep(nanoseconds: NSEC_PER_SEC / 4) } + }, + useNewScope: true + ) + + let task = await store.send(true) + await task.finish(timeout: NSEC_PER_SEC / 2) + XCTExpectFailure { + $0.compactDescription == "There were no in-flight effects to skip." + } + await store.skipInFlightEffects(strict: true) + } + + func testCancelInFlightEffects_NonExhaustive() async { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, action in + .run { _ in try await Task.sleep(nanoseconds: NSEC_PER_SEC) } + } + ) + store.exhaustivity = .off + + await store.send(true) + await store.skipInFlightEffects(strict: false) + } + + func testCancelInFlightEffects_PartialExhaustive() async { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, action in + .run { _ in try await Task.sleep(nanoseconds: NSEC_PER_SEC) } + } + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(true) + await store.skipInFlightEffects(strict: false) + } + + // Confirms that you don't have to receive all actions before the test completes. + func testIgnoreReceiveActions_PartialExhaustive() { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, action in + action ? .init(value: false) : .none + } + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + store.send(true) + } + + // Confirms that you don't have to receive all actions before the test completes. + func testIgnoreReceiveActions_NonExhaustive() { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, action in + action ? .init(value: false) : .none + } + ) + store.exhaustivity = .off + + store.send(true) + } + + // Confirms that all effects do not need to complete before the test completes. + func testIgnoreInFlightEffects_PartialExhaustive() { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, action in + .run { _ in try await Task.sleep(nanoseconds: NSEC_PER_SEC) } + } + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + store.send(true) + } + + // Confirms that all effects do not need to complete before the test completes. + func testIgnoreInFlightEffects_NonExhaustive() { + let store = TestStore( + initialState: 0, + reducer: Reduce { _, action in + .run { _ in try await Task.sleep(nanoseconds: NSEC_PER_SEC) } + } + ) + store.exhaustivity = .off + + store.send(true) + } + + // Confirms that you don't have to assert on all state changes in a non-exhaustive test store. + func testNonExhaustiveSend_PartialExhaustive() { + let store = TestStore( + initialState: Counter.State(), + reducer: Counter() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + store.send(.increment) { + $0.count = 1 + // Ignoring state change: isEven = false + } + store.send(.increment) { + $0.isEven = true + // Ignoring state change: count = 2 + } + store.send(.increment) { + $0.count = 3 + $0.isEven = false + } + } + + func testNonExhaustiveSend_PartialExhaustive_Prefix() { + let store = TestStore( + initialState: Counter.State(), + reducer: Counter() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + store.send(.increment) { + $0.count = 1 + // Ignoring state change: isEven = false + } + } + + // Confirms that you don't have to assert on all state changes in a non-exhaustive test store, + // *but* if you make an incorrect mutation you will still get a failure. + func testNonExhaustiveSend_PartialExhaustive_BadAssertion() { + let store = TestStore( + initialState: Counter.State(), + reducer: Counter() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + +#if DEBUG + XCTExpectFailure { + _ = store.send(.increment) { + $0.count = 0 + } + } issueMatcher: { + $0.compactDescription == """ + A state change does not match expectation: … + +   Counter.State( + − count: 0, + + count: 1, +   isEven: false +   ) + + (Expected: −, Actual: +) + """ + } +#endif + } + + // Confirms that you don't have to assert on all state changes in a non-exhaustive test store, + // *and* that informational boxes of what was not asserted on is not shown. + func testNonExhaustiveSend_NonExhaustive() { + let store = TestStore( + initialState: Counter.State(), + reducer: Counter() + ) + store.exhaustivity = .off + + store.send(.increment) { + $0.count = 1 + // Ignoring state change: isEven = false + } + } + + // Confirms that you can send actions without having received all effect actions in + // non-exhaustive test stores. + func testSend_SkipReceivedActions() { + struct Feature: ReducerProtocol { + struct State: Equatable { + var count = 0 + var isLoggedIn = false + } + enum Action { + case decrement + case increment + case loggedInResponse(Bool) + } + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .decrement: + state.count -= 1 + return .none + case .increment: + state.count += 1 + return Effect(value: .loggedInResponse(true)) + case let .loggedInResponse(response): + state.isLoggedIn = response + return .none + } + } + } + let store = TestStore( + initialState: Feature.State(), + reducer: Feature() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + store.send(.increment) { + $0.count = 1 + } + // Ignored received action: .loggedInResponse(true) + store.send(.decrement) { + $0.count = 0 + // Ignored state change: isLoggedIn = true + } + } + + // Confirms that if you receive an action in a non-exhaustive test store with a bad assertion + // you will still get a failure. + func testSend_SkipReceivedActions_BadAssertion() { + struct Feature: ReducerProtocol { + struct State: Equatable { + var count = 0 + var isLoggedIn = false + } + enum Action: Equatable { + case increment + case loggedInResponse(Bool) + } + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .increment: + state.count += 1 + return Effect(value: .loggedInResponse(true)) + case let .loggedInResponse(response): + state.isLoggedIn = response + return .none + } + } + } + let store = TestStore( + initialState: Feature.State(), + reducer: Feature() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + store.send(.increment) { + $0.count = 1 + } + XCTExpectFailure { + store.receive(.loggedInResponse(true)) { + $0.count = 2 + $0.isLoggedIn = true + } + } issueMatcher: { + $0.compactDescription == """ + A state change does not match expectation: … + +   TestStoreNonExhaustiveTests.Feature.State( + − count: 2, + + count: 1, +   isLoggedIn: true +   ) + + (Expected: −, Actual: +) + """ + } + } + + // Confirms that with non-exhaustive test stores you can send multiple actions without asserting + // on any state changes until the very last action. + func testMultipleSendsWithAssertionOnLast() { + let store = TestStore( + initialState: Counter.State(), + reducer: Counter() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + store.send(.increment) + XCTAssertEqual(store.state, Counter.State(count: 1, isEven: false)) + + store.send(.increment) + XCTAssertEqual(store.state, Counter.State(count: 2, isEven: true)) + + store.send(.increment) { + $0.count = 3 + } + XCTAssertEqual(store.state, Counter.State(count: 3, isEven: false)) + } + + // Confirms that you don't have to assert on all state changes when receiving an action from an + // effect in a non-exhaustive test store. + func testReceive_StateChange() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.onAppear) + XCTAssertEqual(store.state, NonExhaustiveReceive.State(count: 0, int: 0, string: "")) + + await store.receive(.response1(42)) { + // Ignored state change: count = 1 + $0.int = 42 + } + XCTAssertEqual(store.state, NonExhaustiveReceive.State(count: 1, int: 42, string: "")) + + await store.receive(.response2("Hello")) { + // Ignored state change: count = 2 + $0.string = "Hello" + } + XCTAssertEqual(store.state, NonExhaustiveReceive.State(count: 2, int: 42, string: "Hello")) + } + + // Confirms that you can skip receiving certain effect actions in a non-exhaustive test store. + func testReceive_SkipAction() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.onAppear) + XCTAssertEqual(store.state, NonExhaustiveReceive.State(count: 0, int: 0, string: "")) + + // Ignored received action: .response1(42) + + await store.receive(.response2("Hello")) { + $0.count = 2 + $0.string = "Hello" + } + XCTAssertEqual(store.state, NonExhaustiveReceive.State(count: 2, int: 42, string: "Hello")) + } + + // Confirms that you are allowed to send actions without having received all actions queued + // from effects. + func testSendWithUnreceivedAction() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.onAppear) + // Ignored received action: .response1(42) + // Ignored received action: .response2("Hello") + + await store.send(.onAppear) + // Ignored received action: .response1(42) + // Ignored received action: .response2("Hello") + } + + // Confirms that when you send an action the test store skips any unreceived actions + // automatically. + func testSendWithUnreceivedActions_SkipsActions() async { + struct Feature: ReducerProtocol { + enum Action: Equatable { + case tap + case response(Int) + } + func reduce(into state: inout Int, action: Action) -> Effect { + switch action { + case .tap: + state += 1 + return .task { [state] in .response(state + 42) } + case let .response(number): + state = number + return .none + } + } + } + + let store = TestStore( + initialState: 0, + reducer: Feature() + ) + store.exhaustivity = .off + + await store.send(.tap) + XCTAssertEqual(store.state, 1) + + // Ignored received action: .response(43) + await store.send(.tap) + XCTAssertEqual(store.state, 44) + + await store.skipReceivedActions() + XCTAssertEqual(store.state, 86) + } + + /// Commented because the `TestScheduler` doesn't support async await + // func testPartialExhaustivityPrefix() async { + // let testScheduler = TestScheduler(initialClock: 0) + // enum Action { + // case buttonTapped + // case response(Int) + // } + // let store = TestStore( + // initialState: 0, + // reducer: Reduce { state, action in + // switch action { + // case .buttonTapped: + // state += 1 + // return .run { send in + // await send(.response(42)) + // try await testScheduler.sleep(for: .seconds(1)) + // await send(.response(1729)) + // } + // case let .response(number): + // state = number + // return .none + // } + // } + // ) + // store.exhaustivity = .off(showSkippedAssertions: true) + // + // await store.send(.buttonTapped) + // // Ignored state mutation: state = 1 + // // Ignored received action: .response(42) + // await testScheduler.advance(by: .milliseconds(500)) + // await store.send(.buttonTapped) { + // $0 = 43 + // } + // + // await testScheduler.advance(by: .milliseconds(500)) + // await store.skipInFlightEffects() + // await store.skipReceivedActions() + // // Ignored received action: .response(42) + // // Ignored received action: .response(1729) + // // Ignore in-flight effect + // } + + func testCasePathReceive_PartialExhaustive() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.onAppear) + await store.receive(/NonExhaustiveReceive.Action.response1) { + $0.int = 42 + } + await store.receive(/NonExhaustiveReceive.Action.response2) { + $0.string = "Hello" + } + } + + func testCasePathReceive_NonExhaustive() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive() + ) + store.exhaustivity = .off + + await store.send(.onAppear) + await store.receive(/NonExhaustiveReceive.Action.response1) { + $0.int = 42 + } + await store.receive(/NonExhaustiveReceive.Action.response2) { + $0.string = "Hello" + } + } + + func testCasePathReceive_Exhaustive() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive() + ) + + await store.send(.onAppear) + await store.receive(/NonExhaustiveReceive.Action.response1) { + $0.count = 1 + $0.int = 42 + } + await store.receive(/NonExhaustiveReceive.Action.response2) { + $0.count = 2 + $0.string = "Hello" + } + } + + func testCasePathReceive_SkipReceivedAction() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.onAppear) + await store.receive(/NonExhaustiveReceive.Action.response2) { + $0.string = "Hello" + } + } + + func testCasePathReceive_WrongAction() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive(), + useNewScope: true + ) + store.exhaustivity = .off(showSkippedAssertions: true) + await store.send(.onAppear) + + XCTExpectFailure { + $0.compactDescription == """ + Expected to receive an action matching case path, but didn't get one. + """ + } + + await store.receive(/NonExhaustiveReceive.Action.onAppear) + await store.receive(/NonExhaustiveReceive.Action.response1) + await store.receive(/NonExhaustiveReceive.Action.response2) + } + + func testCasePathReceive_ReceivedExtraAction() async { + let store = TestStore( + initialState: NonExhaustiveReceive.State(), + reducer: NonExhaustiveReceive() + ) + store.exhaustivity = .off(showSkippedAssertions: true) + + await store.send(.onAppear) + await store.receive(/NonExhaustiveReceive.Action.response2) + + XCTExpectFailure { + $0.compactDescription == """ + Expected to receive an action matching case path, but didn't get one. + """ + } + + await store.receive(/NonExhaustiveReceive.Action.response2) + } + + // This example comes from Krzysztof Zabłocki's blog post: + // https://www.merowing.info/exhaustive-testing-in-tca/ + func testKrzysztofExample1() { + let store = TestStore( + initialState: KrzysztofExample.State(), + reducer: KrzysztofExample() + ) + store.exhaustivity = .off + + store.send(.changeIdentity(name: "Marek", surname: "Ignored")) { + $0.name = "Marek" + } + } + + // This example comes from Krzysztof Zabłocki's blog post: + // https://www.merowing.info/exhaustive-testing-in-tca/ + func testKrzysztofExample2() { + let store = TestStore( + initialState: KrzysztofExample.State(), + reducer: KrzysztofExample() + ) + store.exhaustivity = .off + + store.send(.changeIdentity(name: "Adam", surname: "Stern")) + store.send(.changeIdentity(name: "Piotr", surname: "Galiszewski")) + store.send(.changeIdentity(name: "Merowing", surname: "Info")) { + $0.name = "Merowing" + $0.surname = "Info" + } + } + + // This example comes from Krzysztof Zabłocki's blog post: + // https://www.merowing.info/exhaustive-testing-in-tca/ + func testKrzysztofExample3() { + let mainQueue = TestScheduler(initialClock: 0) + + let store = TestStore( + initialState: KrzysztofExample.State(), + reducer: KrzysztofExample() + ) + store.exhaustivity = .off + store.dependencies.mainQueue = mainQueue + + store.send(.advanceAgeAndMoodAfterDelay) + mainQueue.advance(by: .seconds(1)) + store.receive(.changeAge(34)) { + $0.age = 34 + } + XCTAssertEqual(store.state.age, 34) + } +} + +struct Counter: ReducerProtocol { + struct State: Equatable { + var count = 0 + var isEven = true + } + enum Action { + case increment + case decrement + } + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .increment: + state.count += 1 + state.isEven.toggle() + return .none + case .decrement: + state.count -= 1 + state.isEven.toggle() + return .none + } + } +} + +struct NonExhaustiveReceive: ReducerProtocol { + struct State: Equatable { + var count = 0 + var int = 0 + var string = "" + } + enum Action: Equatable { + case onAppear + case response1(Int) + case response2(String) + } + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .onAppear: + state = State() + return .merge( + .init(value: .response1(42)), + .init(value: .response2("Hello")) + ) + case let .response1(int): + state.count += 1 + state.int = int + return .none + case let .response2(string): + state.count += 1 + state.string = string + return .none + } + } +} + +// This example comes from Krzysztof Zabłocki's blog post: +// https://www.merowing.info/exhaustive-testing-in-tca/ +struct KrzysztofExample: ReducerProtocol { + struct State: Equatable { + var name: String = "Krzysztof" + var surname: String = "Zabłocki" + var age: Int = 33 + var mood: Int = 0 + } + enum Action: Equatable { + case changeIdentity(name: String, surname: String) + case changeAge(Int) + case changeMood(Int) + case advanceAgeAndMoodAfterDelay + } + + @Dependency(\.mainQueue) var mainQueue + + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case let .changeIdentity(name, surname): + state.name = name + state.surname = surname + return .none + + case .advanceAgeAndMoodAfterDelay: + return .merge( + .init(value: .changeAge(state.age + 1)), + .init(value: .changeMood(state.mood + 1)) + ) + .delay(.seconds(1), scheduler: self.mainQueue) + .eraseToEffect() + + case let .changeAge(age): + state.age = age + return .none + case let .changeMood(mood): + state.mood = mood + return .none + } + } +} +#endif diff --git a/Tests/RxComposableArchitectureTests/TestStoreOldScopeTests.swift b/Tests/RxComposableArchitectureTests/TestStoreOldScopeTests.swift new file mode 100644 index 0000000..8befcb1 --- /dev/null +++ b/Tests/RxComposableArchitectureTests/TestStoreOldScopeTests.swift @@ -0,0 +1,215 @@ +// +// TestStoreOldScopeTests.swift +// +// +// Created by andhika.setiadi on 14/11/22. +// + +import RxSwift +import XCTest + +@testable import RxComposableArchitecture + +/// All Test cases in here using `useNewScope: false` on both Store(...) and TestStore(...) +/// +internal class TestStoreOldScopeTests: XCTestCase { + private let disposeBag = DisposeBag() + + internal func testEffectConcatenation() { + struct CancelID: Hashable {} + struct State: Equatable {} + + enum Action: Equatable { + case a, b1, b2, b3, c1, c2, c3, d + } + + let mainQueue = TestScheduler(initialClock: 0) + + let reducer = Reduce({ _, action in + switch action { + case .a: + return .merge( + Effect.concatenate( + .init(value: .b1), + .init(value: .c1) + ) + .delay(.seconds(1), scheduler: mainQueue) + .eraseToEffect(), + + Observable.never() + .eraseToEffect() + .cancellable(id: CancelID()) + ) + 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: CancelID()) + } + }) + + let store = TestStore( + initialState: State(), + reducer: reducer + ) + + _ = store.send(Action.a) + + mainQueue.advance(by: .seconds(1)) + + store.receive(Action.b1) + store.receive(Action.b2) + store.receive(Action.b3) + + store.receive(Action.c1) + store.receive(Action.c2) + store.receive(Action.c3) + + _ = store.send(Action.d) + } + + #if DEBUG + internal func testExpectedStateEquality() { + struct State: Equatable { + var count: Int = 0 + var isChanging: Bool = false + } + + enum Action: Equatable { + case increment + case changed(from: Int, to: Int) + } + + let reducer = Reduce({ state, action 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 + } + }) + + let store = TestStore( + initialState: State(), + reducer: reducer + ) + + store.send(.increment) { + $0.isChanging = true + } + + store.receive(.changed(from: 0, to: 1)) { + $0.isChanging = false + $0.count = 1 + } + + XCTExpectFailure { + _ = store.send(.increment) { + $0.isChanging = false + } + } + XCTExpectFailure { + store.receive(.changed(from: 1, to: 2)) { + $0.isChanging = true + $0.count = 1100 + } + } + } + + internal func testExpectedStateEqualityMustModify() { + struct State: Equatable { + var count: Int = 0 + } + + enum Action: Equatable { + case noop, finished + } + + let reducer = Reduce({ state, action in + switch action { + case .noop: + return Effect(value: .finished) + case .finished: + return .none + } + }) + + let store = TestStore( + initialState: State(), + reducer: reducer + ) + + store.send(.noop) + store.receive(.finished) + + XCTExpectFailure { + _ = store.send(.noop) { + $0.count = 0 + } + } + + XCTExpectFailure { + store.receive(.finished) { + $0.count = 0 + } + } + } + #endif + + internal func testStateAccess() { + enum Action { case a, b, c, d } + + let store = TestStore( + initialState: 0, + reducer: Reduce({ count, action in + switch action { + case .a: + count += 1 + return .merge(.init(value: .b), .init(value: .c), .init(value: .d)) + case .b, .c, .d: + count += 1 + return .none + } + }) + ) + + store.send(.a) { + $0 = 1 + XCTAssertEqual(store.state, 0) + } + XCTAssertEqual(store.state, 1) + + store.receive(.b) { + $0 = 2 + XCTAssertEqual(store.state, 1) + } + XCTAssertEqual(store.state, 2) + + store.receive(.c) { + $0 = 3 + XCTAssertEqual(store.state, 2) + } + XCTAssertEqual(store.state, 3) + + store.receive(.d) { + $0 = 4 + XCTAssertEqual(store.state, 3) + } + + XCTAssertEqual(store.state, 4) + } +} + diff --git a/Tests/RxComposableArchitectureTests/TestStoreTests.swift b/Tests/RxComposableArchitectureTests/TestStoreTests.swift new file mode 100644 index 0000000..c573bda --- /dev/null +++ b/Tests/RxComposableArchitectureTests/TestStoreTests.swift @@ -0,0 +1,387 @@ +// +// File.swift +// +// +// Created by andhika.setiadi on 13/11/22. +// + +import RxSwift +import XCTest + +@testable import RxComposableArchitecture + +/// All Test cases in here using `useNewScope: true` on both Store(...) and TestStore(...) +/// +@MainActor +internal class TestStoreTests: XCTestCase { + private let disposeBag = DisposeBag() + + internal func testEffectConcatenation() async { + struct CancelID: Hashable {} + struct State: Equatable {} + + enum Action: Equatable { + case a, b1, b2, b3, c1, c2, c3, d + } + + let mainQueue = TestScheduler(initialClock: 0) + + let reducer = Reduce({ _, action in + switch action { + case .a: + return .merge( + Effect.concatenate( + .init(value: .b1), + .init(value: .c1) + ) + .delay(.seconds(1), scheduler: mainQueue) + .eraseToEffect(), + + Observable.never() + .eraseToEffect() + .cancellable(id: CancelID()) + ) + 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: CancelID()) + } + }) + + let store = TestStore( + initialState: State(), + reducer: reducer, + useNewScope: true + ) + + _ = await store.send(Action.a) + + mainQueue.advance(by: .seconds(1)) + + await store.receive(Action.b1) + await store.receive(Action.b2) + await store.receive(Action.b3) + + await store.receive(Action.c1) + await store.receive(Action.c2) + await store.receive(Action.c3) + + _ = await store.send(Action.d) + } + + internal func testAsync() async { + enum Action: Equatable { + case tap + case response(Int) + } + + let store = TestStore( + initialState: 0, + reducer: Reduce({ state, action in + switch action { + case .tap: + return .task { .response(42) } + case let .response(number): + state = number + return .none + } + }), + useNewScope: true + ) + + _ = await store.send(.tap) + await store.receive(.response(42)) { + $0 = 42 + } + } + +#if DEBUG + internal func testExpectedStateEquality() async { + struct State: Equatable { + var count: Int = 0 + var isChanging: Bool = false + } + + enum Action: Equatable { + case increment + case changed(from: Int, to: Int) + } + + let reducer = Reduce({ state, action 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 + } + }) + + let store = TestStore( + initialState: State(), + reducer: reducer, + useNewScope: true + ) + + _ = await store.send(.increment) { + $0.isChanging = true + } + + await store.receive(.changed(from: 0, to: 1)) { + $0.isChanging = false + $0.count = 1 + } + + XCTExpectFailure { + _ = store.send(.increment) { + $0.isChanging = false + } + } + XCTExpectFailure { + store.receive(.changed(from: 1, to: 2)) { + $0.isChanging = true + $0.count = 1100 + } + } + } + + internal func testExpectedStateEqualityMustModify() async { + struct State: Equatable { + var count: Int = 0 + } + + enum Action: Equatable { + case noop, finished + } + + let reducer = Reduce({ state, action in + switch action { + case .noop: + return Effect(value: .finished) + case .finished: + return .none + } + }) + + let store = TestStore( + initialState: State(), + reducer: reducer, + useNewScope: true + ) + + _ = await store.send(.noop) + await store.receive(.finished) + + XCTExpectFailure { + _ = store.send(.noop) { + $0.count = 0 + } + } + + XCTExpectFailure { + store.receive(.finished) { + $0.count = 0 + } + } + } + + func testReceiveActionMatchingPredicate() async { + enum Action: Equatable { + case noop, finished + } + + let reducer = Reduce(internal: { state, action in + switch action { + case .noop: + return Effect(value: .finished) + case .finished: + return .none + } + }) + + let store = TestStore(initialState: 0, reducer: reducer) + + let predicateShouldBeCalledExpectation = expectation( + description: "predicate should be called") + await store.send(.noop) + await store.receive { action in + predicateShouldBeCalledExpectation.fulfill() + return action == .finished + } + wait(for: [predicateShouldBeCalledExpectation], timeout: 0) + + XCTExpectFailure { + store.send(.noop) + store.receive(.noop) + } + + XCTExpectFailure { + store.send(.noop) + store.receive { $0 == .noop } + } + } + +#endif + + internal func testStateAccess() async { + enum Action { case a, b, c, d } + + let store = TestStore( + initialState: 0, + reducer: Reduce({ count, action in + switch action { + case .a: + count += 1 + return .merge(.init(value: .b), .init(value: .c), .init(value: .d)) + case .b, .c, .d: + count += 1 + return .none + } + }), + useNewScope: true + ) + + _ = await store.send(.a) { + $0 = 1 + XCTAssertEqual(store.state, 0) + } + XCTAssertEqual(store.state, 1) + + await store.receive(.b) { + $0 = 2 + XCTAssertEqual(store.state, 1) + } + XCTAssertEqual(store.state, 2) + + 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) + } + + func testOverrideDependenciesDirectlyOnReducer() { + struct Counter: ReducerProtocol { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession + + func reduce(into state: inout Int, action: Bool) -> Effect { + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none + } + } + + let store = TestStore( + initialState: 0, + reducer: Counter() + .dependency(\.calendar, Calendar(identifier: .gregorian)) + .dependency(\.locale, Locale(identifier: "en_US")) + .dependency(\.timeZone, TimeZone(secondsFromGMT: 0)!) + .dependency(\.urlSession, URLSession(configuration: .ephemeral)) + ) + + store.send(true) { $0 = 1 } + } + + func testOverrideDependenciesOnTestStore() { + struct Counter: ReducerProtocol { + @Dependency(\.calendar) var calendar + @Dependency(\.locale) var locale + @Dependency(\.timeZone) var timeZone + @Dependency(\.urlSession) var urlSession + + func reduce(into state: inout Int, action: Bool) -> Effect { + _ = self.calendar + _ = self.locale + _ = self.timeZone + _ = self.urlSession + state += action ? 1 : -1 + return .none + } + } + + let store = TestStore( + initialState: 0, + reducer: Counter() + ) + store.dependencies.calendar = Calendar(identifier: .gregorian) + store.dependencies.locale = Locale(identifier: "en_US") + store.dependencies.timeZone = TimeZone(secondsFromGMT: 0)! + store.dependencies.urlSession = URLSession(configuration: .ephemeral) + + store.send(true) { $0 = 1 } + } + + func testDependenciesEarlyBinding() async { + struct Feature: ReducerProtocol { + struct State: Equatable { + var count = 0 + var date: Date + init() { + @Dependency(\.date.now) var now: Date + self.date = now + } + } + enum Action: Equatable { + case tap + case response(Int) + } + @Dependency(\.date.now) var now: Date + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .tap: + state.count += 1 + return .task { .response(42) } + case let .response(number): + state.count = number + state.date = now + return .none + } + } + } + + let store = TestStore( + initialState: Feature.State(), + reducer: Feature() + ) { + $0.date = .constant(Date(timeIntervalSince1970: 1_234_567_890)) + } + + await store.send(.tap) { + @Dependency(\.date.now) var now: Date + $0.count = 1 + $0.date = now + } + await store.receive(.response(42)) { + @Dependency(\.date.now) var now: Date + $0.count = 42 + $0.date = now + } + } +} diff --git a/development-podspecs/xctest-dynamic-overlay.podspec.json b/development-podspecs/xctest-dynamic-overlay.podspec.json new file mode 100644 index 0000000..8fed70f --- /dev/null +++ b/development-podspecs/xctest-dynamic-overlay.podspec.json @@ -0,0 +1,20 @@ +{ + "name": "XCTestDynamicOverlay", + "version": "0.5.0", + "authors": "local pod", + "homepage": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "summary": "local pod", + "license": { + "type": "MIT", + "file": "LICENSE" + }, + "platforms": { + "ios": "13.0" + }, + "source": { + "git": "https://github.com/pointfreeco/xctest-dynamic-overlay", + "tag": "0.5.0" + }, + "source_files": "Sources/**/*.swift", + "swift_version": "5.0" +}