From 226cdd92011c0e16d76245b9b4330ecb640e48e4 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Mon, 28 Nov 2022 17:04:42 -0500 Subject: [PATCH] Fix 0.48.1 cherry pick ## Changes Previous 0.48.1 cherry pick somehow wasn't properly done (possibly due to `git rerere`) and some changes were missing. This commit contains all upstream changes from 0.47.2 ... 0.48.1 which were then reapplied on top of `master`. This should hopefully now include all changes. To avoid issues with `git rerere` I cleared its cache before doing this operation. ## Automatic git commit (cherry picked from commit 1c19319aed7ab85e10e07de570f6160e46140d2b) Apostrocide. (#1701) (cherry picked from commit 937a716d1af205f5e9b6f512c88012d7ad3e3f02) Typo (#1702) wow! (cherry picked from commit 1c50ffcf440de19e15e3a8b94e7347df0895a6ce) Base `OpenURL` dependency on SwiftUI (#1714) * Base `OpenURL` dependency on SwiftUI * indent (cherry picked from commit fbd70bf4d01284a9788d88cba4b6c70827f104e4) Fix ButtonRole and Button deprecations (#1715) Replaced AlertState.ButtonRole with ButtonState.Role Replaced AlertState.Button with ButtonState (cherry picked from commit 907eb1866a9de2c506a05edee9d277b856a7171c) # Conflicts: # Package.resolved Run swift-format (cherry picked from commit f56a0d1973d92fa82066ed3bd7f9ba7375fca5fe) add await (#1725) (cherry picked from commit 47465c3f265cf058447c32c58a6676216ed76590) # Conflicts: # Sources/Dependencies/Dependencies/MainQueue.swift Fix a possible typo (#1762) (cherry picked from commit d0b1444393d9e209307dcad066cb8d19a667eda9) Bump SwiftUINavigation and update examples (#1760) * bump navigation * bump swiftui-navigation * Add SearchView preview * make login sendable * use button state builder * format * bump swift and platform version * remove unused test clock * Add quotes to scheme (cherry picked from commit fe5603ec380e2a312cbf8ff45e12e81eea5c072b) Fix minor document error (#1764) (cherry picked from commit b555bfeac62c8c95cb54b002442bb8068aa99311) Fix code typo (#1773) Signed-off-by: Daeyoung Kim Signed-off-by: Daeyoung Kim (cherry picked from commit 315d935fcd2021828310294c2d85fc747f3de332) Update latest version documentation link. (#1776) (cherry picked from commit 43291b21dc24e1981d2bc8e1b715a5ce733c3e23) Add @_unsafeInheritExecutor to withTaskCancellation(id:) (#1779) * Add @_unsafeInheritExecutor to withTaskCancellation(id:) * wip (cherry picked from commit 48f0cc66000a23690ebbd7b65ea57fb7e7769825) # Conflicts: # Sources/ComposableArchitecture/Effects/Cancellation.swift resolve binding key path crash (#1784) (cherry picked from commit fbe68471a3cf4eef6f0501d07642ee70b5bc3e84) # Conflicts: # Sources/ComposableArchitecture/ViewStore.swift Run swift-format (cherry picked from commit 8f19bf88aeb67c33e41e14eefa30f9074ddca013) Simplify/fix #1784 (#1785) * Simplify/fix #1784 This PR works on top of #1784 and: * Reduces the number of moving parts * Restores implicit animations by directly producing the binding from the observable object * Strongly retains the store in the binding to avoid losing writes * Move (cherry picked from commit 626c35c7dea962bd3246e1f019d91cbfe419e695) Make TestStore.receive actually match the action predicate (#1780) * Assert that TestStore.receive(...) actually calls its predicate * Fix TestStore.receive to respect its action predicate * Update TestStore failure tests to use new description strings (cherry picked from commit 5f294b9dc9a047b1e6b6766752da2856a158750e) # Conflicts: # Tests/ComposableArchitectureTests/TestStoreTests.swift Run swift-format (cherry picked from commit 5a8df942237aa1ff59b7abccfe18bff74d1bbe4a) Fix TestStore docs (#1787) * Fix TestStore docs * fix * wip (cherry picked from commit ea9cc86779038a0e1d38aa080bdc636e236190b6) # Conflicts: # Sources/ComposableArchitecture/TestStore.swift Deprecate 'Effect' for 'EffectTask' (#1788) * Deprecate 'Effect' for 'EffectTask' This is to allow reclaiming 'Effect' at a later time without the 'Failure' generic in the near future. * wip (cherry picked from commit 32f967c0b84099e29dc5bce062aa9f45493d2688) # Conflicts: # Sources/ComposableArchitecture/Effect.swift Soft-deprecate EffectPublisher (#1791) * Soft-deprecate EffectPublisher in favor of EffectTask. * wip (cherry picked from commit 4266744f047734de8eb8bc73730d7f7d25cbd775) # Conflicts: # Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md # Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md # Sources/ComposableArchitecture/Effect.swift Run swift-format (cherry picked from commit ce196d7c152172cc924dcc875338a6459fcd568a) Deprecate async version of withValue and introduce sync version. (#1792) (cherry picked from commit 5eedf980b72dd6fcd3a65c01dfd644a929d2223b) # Conflicts: # Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift # Sources/ComposableArchitecture/Internal/Deprecations.swift Run swift-format (cherry picked from commit ad714bacb9dd1b3addfb27d5021ca8ad33b62009) Wrong cancel id on testNestedCancels (#1793) (cherry picked from commit 1a65878210b8bfccc7c73436718195582562c314) # Conflicts: # Tests/ComposableArchitectureTests/EffectCancellationTests.swift Allow `BindingReducer` to work with `ViewState` (#1790) * Simpler bindable view state * wip * wip * Remove `BindingStore` * wip * wip * wip * wip * wip * wip * wip * wip * wip * Make BindingState conditionally sendable. * Fixed tests * Update bindings article * Add some additional contextual information to the runtime warnings * update some docs * lots more docs * test clean up * Improve diagnostic * Put back `BindingReducer()` in the form study * clean up * Improve DocC references * Remove DocC references for `@`'d property wrappers. * wip Co-authored-by: Stephen Celis Co-authored-by: Brandon Williams (cherry picked from commit c719fa1d3921533f25a6aa6c41016ce5d41a250d) # Conflicts: # Sources/ComposableArchitecture/Internal/Deprecations.swift # Sources/ComposableArchitecture/SwiftUI/Binding.swift # Tests/ComposableArchitectureTests/BindingTests.swift # Tests/ComposableArchitectureTests/DebugTests.swift Run swift-format (cherry picked from commit d1c2e5b6d1c4e3b2f46c4fa1983b6ed4acab9173) Revert #1790 (#1795) It occurred to us that this solution unfortunately is incompatible with view actions. We have an alternate solution that works, so I'll PR that in the future if no others materialize! (cherry picked from commit 30015d13a3ca0988b4674a83eccebbea747fe1f8) # Conflicts: # Sources/ComposableArchitecture/Internal/Deprecations.swift # Sources/ComposableArchitecture/SwiftUI/Binding.swift Revert #1784: Resolve binding key path crash (#1799) * Revert "Simplify/fix #1784 (#1785)" This reverts commit 626c35c7dea962bd3246e1f019d91cbfe419e695. * Revert "resolve binding key path crash (#1784)" This reverts commit fbe68471a3cf4eef6f0501d07642ee70b5bc3e84. (cherry picked from commit cbf8a45fa97ca4afb858f6cd99730bb67952813a) # Conflicts: # Sources/ComposableArchitecture/ViewStore.swift --- .../01-GettingStarted-FocusState.swift | 1 - Package.resolved | 4 +- .../ComposableArchitecture.md | 2 +- .../Deprecations/SwiftUIDeprecations.md | 4 - .../Documentation.docc/Extensions/Effect.md | 26 +- .../Extensions/TestStore.md | 4 +- Sources/ComposableArchitecture/Effect.swift | 169 ++++---- .../Effects/ConcurrencySupport.swift | 6 +- .../Internal/Deprecations.swift | 17 + .../SwiftUI/Binding.swift | 12 +- .../ComposableArchitecture/TestStore.swift | 384 ++++++++++-------- .../ComposableArchitecture/ViewStore.swift | 50 +-- .../EffectCancellationTests.swift | 2 +- .../TestStoreFailureTests.swift | 2 +- .../TestStoreNonExhaustiveTests.swift | 4 +- .../TestStoreTests.swift | 36 ++ 16 files changed, 389 insertions(+), 334 deletions(-) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift index c293d61f7..d91aaacf9 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/01-GettingStarted-FocusState.swift @@ -57,7 +57,6 @@ struct FocusDemoView: View { VStack { TextField("Username", text: viewStore.binding(\.$username)) .focused($focusedField, equals: .username) - SecureField("Password", text: viewStore.binding(\.$password)) .focused($focusedField, equals: .password) Button("Sign In") { diff --git a/Package.resolved b/Package.resolved index 3134e560a..05f8c3261 100644 --- a/Package.resolved +++ b/Package.resolved @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "ddc01cdcddfd30ef7a966049b2e1d251e224ad93", - "version" : "0.5.0" + "revision" : "46acf5ecc1cabdb28d7fe03289f6c8b13a023f52", + "version" : "0.4.5" } }, { diff --git a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md index 447db5686..32430f193 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md +++ b/Sources/ComposableArchitecture/Documentation.docc/ComposableArchitecture.md @@ -13,7 +13,7 @@ ### State management - ``ReducerProtocol`` -- ``EffectProducer`` +- ``EffectTask`` - ``Store`` - ``ViewStore`` diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md index 0b0f81ebc..58d84ee26 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Deprecations/SwiftUIDeprecations.md @@ -33,9 +33,5 @@ Avoid using deprecated APIs in your app. Select a method to see the replacement - ``WithViewStore/Action`` - ``WithViewStore/State`` -### View state - -- ``ActionSheetState`` - diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md index 06776bfc3..30c7f628f 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/Effect.md @@ -1,27 +1,27 @@ -# ``ComposableArchitecture/EffectProducer`` +# ``ComposableArchitecture/EffectTask`` ## Topics ### Creating an effect -- ``none`` -- ``task(priority:operation:catch:file:fileID:line:)`` -- ``run(priority:operation:catch:file:fileID:line:)`` -- ``fireAndForget(priority:_:)`` +- ``EffectProducer/none`` +- ``EffectProducer/task(priority:operation:catch:file:fileID:line:)`` +- ``EffectProducer/run(priority:operation:catch:file:fileID:line:)`` +- ``EffectProducer/fireAndForget(priority:_:)`` - ``TaskResult`` ### Cancellation -- ``cancellable(id:cancelInFlight:)-29q60`` -- ``cancel(id:)-6hzsl`` -- ``cancel(ids:)-1cqqx`` +- ``EffectProducer/cancellable(id:cancelInFlight:)-29q60`` +- ``EffectProducer/cancel(id:)-6hzsl`` +- ``EffectProducer/cancel(ids:)-1cqqx`` - ``withTaskCancellation(id:cancelInFlight:operation:)-4dtr6`` ### Composition -- ``map(_:)-yn70`` -- ``merge(_:)-45guh`` -- ``merge(_:)-3d54p`` +- ``EffectProducer/map(_:)-yn70`` +- ``EffectProducer/merge(_:)-45guh`` +- ``EffectProducer/merge(_:)-3d54p`` ### Concurrency @@ -29,11 +29,11 @@ ### Testing -- ``unimplemented(_:)`` +- ``EffectProducer/unimplemented(_:)`` ### SwiftUI integration -- ``animation(_:)`` +- ``EffectProducer/animation(_:)`` ### Deprecations diff --git a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md index c078fb700..8608df50d 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Extensions/TestStore.md @@ -4,7 +4,7 @@ ### Creating a test store -- ``init(initialState:reducer:file:line:)`` +- ``init(initialState:reducer:prepareDependencies:file:line:)`` ### Configuring a test store @@ -18,7 +18,7 @@ - ``receive(_:timeout:assert:file:line:)-1rwdd`` - ``receive(_:timeout:assert:file:line:)-4e4m0`` - ``receive(_:timeout:assert:file:line:)-3myco`` -- ``finish(timeout:file:line:)`` +- ``finish(timeout:file:line:)-53gi5`` - ``TestStoreTask`` ### Methods for skipping actions and effects diff --git a/Sources/ComposableArchitecture/Effect.swift b/Sources/ComposableArchitecture/Effect.swift index f6c5a1d9b..96aca520e 100644 --- a/Sources/ComposableArchitecture/Effect.swift +++ b/Sources/ComposableArchitecture/Effect.swift @@ -6,35 +6,55 @@ import XCTestDynamicOverlay import SwiftUI #endif -/// 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 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. -/// -/// There are 2 distinct ways to create an `Effect`: one using Swift's native concurrency tools, and -/// the other using ReactiveSwift 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: -/// * ``EffectProducer/task(priority:operation:catch:file:fileID:line:)`` -/// * ``EffectProducer/run(priority:operation:catch:file:fileID:line:)`` -/// * ``EffectProducer/fireAndForget(priority:_:)`` -/// * If using ReactiveSwift in your application, in particular for the dependencies of your feature -/// then you can create effects by making use of any of ReactiveSwift's operators, and then erasing the -/// publisher type to ``EffectProducer`` with either `eraseToEffect` or `catchToEffect`. Note that -/// the ReactiveSwift interface to ``EffectProducer`` 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 ReactiveSwift interface of ``EffectProducer`` as mentioned -/// above. If you are using Swift's concurrency tools and the `.task`, `.run` and `.fireAndForget` -/// functions on ``EffectTask``, then threading is automatically handled for you. +/// This type is deprecated in favor of ``EffectTask``. See its documentation for more information. +@available( + iOS, + deprecated: 9999.0, + message: + """ + 'EffectProducer' has been deprecated in favor of 'EffectTask'. + + You are encouraged to use `EffectTask` to model the ouput of your reducers, and to use Swift concurrency to model asynchrony in dependencies. + + See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 + """ +) +@available( + macOS, + deprecated: 9999.0, + message: + """ + 'EffectProducer' has been deprecated in favor of 'EffectTask'. + + You are encouraged to use `EffectTask` to model the ouput of your reducers, and to use Swift concurrency to model asynchrony in dependencies. + + See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 + """ +) +@available( + tvOS, + deprecated: 9999.0, + message: + """ + 'EffectProducer' has been deprecated in favor of 'EffectTask'. + + You are encouraged to use `EffectTask` to model the ouput of your reducers, and to use Swift concurrency to model asynchrony in dependencies. + + See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 + """ +) +@available( + watchOS, + deprecated: 9999.0, + message: + """ + 'EffectProducer' has been deprecated in favor of 'EffectTask'. + + You are encouraged to use `EffectTask` to model the ouput of your reducers, and to use Swift concurrency to model asynchrony in dependencies. + + See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 + """ +) public struct EffectProducer { @usableFromInline enum Operation { @@ -63,20 +83,38 @@ extension EffectProducer { } } -/// A convenience type alias for referring to an effect that can never fail, like the kind of -/// ``EffectProducer`` returned by a reducer after processing an action. +/// A type that encapsulates a unit of work that can be run in the outside world, and can feed +/// actions back to the ``Store``. /// -/// Instead of specifying `Never` as `Failure`: +/// 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. /// -/// ```swift -/// func reduce(into state: inout State, action: Action) -> EffectProducer { … } -/// ``` +/// There are 2 distinct ways to create an `Effect`: one using Swift's native concurrency tools, and +/// the other using Apple's Combine framework: /// -/// You can specify a single generic: +/// * 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: +/// * ``EffectProducer/task(priority:operation:catch:file:fileID:line:)`` +/// * ``EffectProducer/run(priority:operation:catch:file:fileID:line:)`` +/// * ``EffectProducer/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 ``EffectProducer`` with either `eraseToEffect` or `catchToEffect`. Note that +/// the Combine interface to ``EffectProducer`` is considered soft deprecated, and you should +/// eventually port to Swift's native concurrency tools. /// -/// ```swift -/// func reduce(into state: inout State, action: Action) -> EffectTask { … } -/// ``` +/// > Important: The publisher interface to ``EffectTask`` is considered deperecated, and you should +/// try converting any uses of that interface to Swift's native concurrency tools. +/// > +/// > Also, ``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 ``EffectProducer`` as mentioned +/// above. If you are using Swift's concurrency tools and the `.task`, `.run` and `.fireAndForget` +/// functions on ``EffectTask``, then threading is automatically handled for you. public typealias EffectTask = Effect extension EffectProducer where Failure == Never { @@ -614,57 +652,20 @@ extension EffectProducer { } @available( - iOS, - deprecated: 9999.0, + *, message: """ - 'Effect' has been deprecated in favor of 'EffectTask' when `Failure == Never`, or - `EffectProducer` in general. + 'Effect' has been deprecated in favor of 'EffectTask' when 'Failure == Never', or 'EffectProducer' in general. - You are encouraged to use `EffectTask` to model the ouput of your reducers, and to Swift - concurrency to model failable streams of values. + You are encouraged to use 'EffectTask' to model the output of your reducers, and to use Swift concurrency to model failable streams of values. - See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 - """ -) -@available( - macOS, - deprecated: 9999.0, - message: - """ - 'Effect' has been deprecated in favor of 'EffectTask' when `Failure == Never`, or - `EffectProducer` in general. - - You are encouraged to use `EffectTask` to model the ouput of your reducers, and to Swift - concurrency to model failable streams of values. + To find and replace instances of 'Effect' to 'EffectTask' in your codebase, use the following regular expression: - See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 - """ -) -@available( - tvOS, - deprecated: 9999.0, - message: - """ - 'Effect' has been deprecated in favor of 'EffectTask' when `Failure == Never`, or - `EffectProducer` in general. - - You are encouraged to use `EffectTask` to model the ouput of your reducers, and to Swift - concurrency to model failable streams of values. + Find: + Effect<([^,]+), Never> - See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 - """ -) -@available( - watchOS, - deprecated: 9999.0, - message: - """ - 'Effect' has been deprecated in favor of 'EffectTask' when `Failure == Never`, or - `EffectProducer` in general. - - You are encouraged to use `EffectTask` to model the ouput of your reducers, and to Swift - concurrency to model failable streams of values. + Replace: + EffectTask<$1> See the migration roadmap for more information: https://github.com/pointfreeco/swift-composable-architecture/discussions/1477 """ diff --git a/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift b/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift index aa081e0df..8995d909c 100644 --- a/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift +++ b/Sources/ComposableArchitecture/Effects/ConcurrencySupport.swift @@ -326,11 +326,11 @@ /// - Parameters: operation: An operation to be performed on the actor with the underlying value. /// - Returns: The result of the operation. public func withValue( - _ operation: @Sendable (inout Value) async throws -> T - ) async rethrows -> T { + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { var value = self.value defer { self.value = value } - return try await operation(&value) + return try operation(&value) } /// Overwrite the isolated value with a new value. diff --git a/Sources/ComposableArchitecture/Internal/Deprecations.swift b/Sources/ComposableArchitecture/Internal/Deprecations.swift index e9e982b0c..b64e9734c 100644 --- a/Sources/ComposableArchitecture/Internal/Deprecations.swift +++ b/Sources/ComposableArchitecture/Internal/Deprecations.swift @@ -10,6 +10,23 @@ import XCTestDynamicOverlay import os #endif +// MARK: - Deprecated after 0.47.2 + +extension ActorIsolated { + @available( + *, + deprecated, + message: "Use the non-async version of 'withValue'." + ) + public func withValue( + _ operation: @Sendable (inout Value) async throws -> T + ) async rethrows -> T { + var value = self.value + defer { self.value = value } + return try await operation(&value) + } +} + // MARK: - Deprecated after 0.45.0: #if canImport(SwiftUI) diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index 213197634..874a68015 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -82,12 +82,11 @@ extension BindableState: CustomReflectable { } } -// Until we can use swift-custom-dump this has to be commented out -// extension BindableState: CustomDumpRepresentable { -// public var customDumpValue: Any { -// self.wrappedValue -// } -// } +extension BindableState: CustomDumpRepresentable { + public var customDumpValue: Any { + self.wrappedValue + } +} extension BindableState: CustomDebugStringConvertible where Value: CustomDebugStringConvertible { public var debugDescription: String { @@ -157,7 +156,6 @@ extension BindableAction { ) } } - #endif /// An action that describes simple mutations to some root state at a writable key path. diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 6e1ae4018..39d782c75 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -10,9 +10,9 @@ import XCTestDynamicOverlay /// A testable runtime for a reducer. /// /// This object aids in writing expressive and exhaustive tests for features built in the -/// Composable Architecture. It allows you to send a sequence of actions to the store, and each -/// step of the way you must assert exactly how state changed, and how effect emissions were fed -/// back into the system. +/// Composable Architecture. It allows you to send a sequence of actions to the store, and each step +/// of the way you must assert exactly how state changed, and how effect emissions were fed back +/// into the system. /// /// See the dedicated article for detailed information on testing. /// @@ -22,28 +22,27 @@ import XCTestDynamicOverlay /// 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. +/// * After each action is sent you must describe precisely how the state changed from before the +/// action was sent to after it was sent. /// -/// If even the smallest piece of data differs the test will fail. This guarantees that you -/// are proving you know precisely how the state of the system changes. +/// 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 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. +/// * 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 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. +/// 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 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 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. +/// 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: /// @@ -96,11 +95,11 @@ import XCTestDynamicOverlay /// ``` /// /// 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`. +/// 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`. /// -/// 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: +/// 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: /// /// ```swift /// await store.send(.incrementButtonTapped) { @@ -119,8 +118,8 @@ import XCTestDynamicOverlay /// (Expected: −, Actual: +) /// ``` /// -/// For a more complex example, consider the following bare-bones search feature that uses a -/// clock and cancel token to debounce requests: +/// For a more complex example, consider the following bare-bones search feature that uses a clock +/// and cancel token to debounce requests: /// /// ```swift /// struct Search: ReducerProtocol { @@ -227,8 +226,8 @@ import XCTestDynamicOverlay /// ] /// ``` /// -/// 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 +/// 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: @@ -244,14 +243,14 @@ import XCTestDynamicOverlay /// ``` /// /// 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. +/// 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. +/// 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 @@ -264,9 +263,9 @@ import XCTestDynamicOverlay /// 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. +/// ``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. @@ -369,8 +368,8 @@ import XCTestDynamicOverlay /// 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 +/// 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 @@ -430,13 +429,12 @@ public final class TestStore @@ -817,9 +815,9 @@ extension TestStore where ScopedState: Equatable { /// } /// ``` /// - /// This method suspends in order to allow any effects to start. For example, if you - /// track an analytics event in a ``EffectProducer/fireAndForget(priority:_:)`` when an action is - /// sent, you can assert on that behavior immediately after awaiting `store.send`: + /// This method suspends in order to allow any effects to start. For example, if you track an + /// analytics event in a ``EffectProducer/fireAndForget(priority:_:)`` when an action is sent, + /// you can assert on that behavior immediately after awaiting `store.send`: /// /// ```swift /// @MainActor @@ -843,8 +841,8 @@ extension TestStore where ScopedState: Equatable { /// } /// ``` /// - /// 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. + /// 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 @@ -940,10 +938,10 @@ extension TestStore where ScopedState: Equatable { /// 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. + /// 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 @@ -1157,7 +1155,7 @@ extension TestStore where ScopedState: Equatable { extension TestStore where ScopedState: Equatable, Action: Equatable { /// Asserts an action was received from an effect and asserts when state changes. /// - /// See ``receive(_:timeout:assert:file:line:)-332q2`` for more information of how to use this + /// See ``receive(_:timeout:assert:file:line:)-1rwdd`` for more information of how to use this /// method. /// /// - Parameters: @@ -1178,30 +1176,18 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { ) { self.receiveAction( matching: { expectedAction == $0 }, - failureMessage: "Expected to receive an action \(expectedAction), but didn't get one.", - onReceive: { receivedAction in - if expectedAction != receivedAction { - let difference = TaskResultDebugging.$emitRuntimeWarnings.withValue(false) { - diff(expectedAction, receivedAction, format: .proportional) - .map { "\($0.indent(by: 4))\n\n(Expected: −, Received: +)" } - ?? """ - Expected: - \(String(describing: expectedAction).indent(by: 2)) - - Received: - \(String(describing: receivedAction).indent(by: 2)) - """ - } - - XCTFailHelper( + 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)) """ - Received unexpected action: … - - \(difference) - """, - file: file, - line: line - ) } }, updateStateToExpectedResult, @@ -1212,12 +1198,12 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { /// 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 + /// See ``receive(_:timeout:assert:file:line:)-3myco`` 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. + /// - isMatching: A closure that attempts to match an action. If it returns `false`, 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 @@ -1228,29 +1214,18 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { @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( - _ matching: (Action) -> Bool, + _ isMatching: (Action) -> Bool, assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line ) { self.receiveAction( - matching: matching, - failureMessage: "Expected to receive a matching action, but didn't get one.", - onReceive: { receivedAction in + matching: isMatching, + failureMessage: "Expected to receive an action matching predicate, but didn't get one.", + unexpectedActionDescription: { receivedAction in var action = "" customDump(receivedAction, to: &action, indent: 2) - XCTFailHelper( - """ - Received action without asserting on payload: - - \(action) - """, - overrideExhaustivity: self.exhaustivity == .on - ? .off(showSkippedAssertions: true) - : self.exhaustivity, - file: file, - line: line - ) + return action }, updateStateToExpectedResult, file: file, @@ -1260,11 +1235,11 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { /// 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 + /// See ``receive(_:timeout:assert:file:line:)-4e4m0`` for more information of how to use this /// method. /// /// - Parameters: - /// - casePath: A case path identifying the case of an action to enum to receive + /// - actionCase: A case path identifying the case of an action 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 @@ -1274,29 +1249,18 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { @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( - _ casePath: CasePath, + _ actionCase: CasePath, assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line ) { self.receiveAction( - matching: { casePath.extract(from: $0) != nil }, - failureMessage: "Expected to receive a matching action, but didn't get one.", - onReceive: { receivedAction in + 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) - XCTFailHelper( - """ - Received action without asserting on payload: - - \(action) - """, - overrideExhaustivity: self.exhaustivity == .on - ? .off(showSkippedAssertions: true) - : self.exhaustivity, - file: file, - line: line - ) + return action }, updateStateToExpectedResult, file: file, @@ -1309,21 +1273,21 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { #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: + /// 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.send(.buttonTapped) /// 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 + /// 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` @@ -1354,34 +1318,33 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { ) } - /// Asserts an action was received from an effect that matches a predicate, and asserts how - /// the state changes. + /// 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: + /// This method is similar to ``receive(_:timeout:assert:file:line:)-4he05``, 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 } + /// guard case .response(.success) = $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. + /// 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. + /// If you only want to check that a particular action case was received, then you might find + /// the ``receive(_:timeout:assert:file:line:)-4he05`` 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. + /// - isMatching: A closure that attempts to match an action. If it returns `false`, 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 @@ -1391,14 +1354,14 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { @MainActor @_disfavoredOverload public func receive( - _ matching: (Action) -> Bool, + _ isMatching: (Action) -> Bool, timeout duration: Duration, assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, line: UInt = #line ) async { await self.receive( - matching, + isMatching, timeout: duration.nanoseconds, assert: updateStateToExpectedResult, file: file, @@ -1409,8 +1372,25 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { /// 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. + /// 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(.buttonTapped) + /// 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. @@ -1442,14 +1422,33 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { await Task.megaYield() } - /// Asserts a matching action was received from an effect and asserts how the state changes. + /// Asserts an action was received from an effect that matches a predicate, and asserts how the + /// state changes. /// - /// See ``receive(_:timeout:assert:file:line:)-6b3xi`` for more information on how to use this - /// method. + /// This method is similar to ``receive(_:timeout:assert:file:line:)-1rwdd``, 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(.success) = $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:)-4e4m0`` 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. + /// - isMatching: A closure that attempts to match an action. If it returns `false`, 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 @@ -1458,7 +1457,7 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { @MainActor @_disfavoredOverload public func receive( - _ matching: (Action) -> Bool, + _ isMatching: (Action) -> Bool, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, @@ -1467,24 +1466,43 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { guard !self.reducer.inFlightEffects.isEmpty else { _ = { - self.receive(matching, assert: updateStateToExpectedResult, file: file, line: line) + self.receive(isMatching, assert: updateStateToExpectedResult, file: file, line: line) }() return } await self.receiveAction(timeout: nanoseconds, file: file, line: line) _ = { - self.receive(matching, assert: updateStateToExpectedResult, 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. + /// This method is similar to ``receive(_:timeout:assert:file:line:)-1rwdd``, 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: - /// - casePath: A case path identifying the case of an action to enum to receive + /// - actionCase: A case path identifying the case of an action 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 @@ -1493,7 +1511,7 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { @MainActor @_disfavoredOverload public func receive( - _ casePath: CasePath, + _ actionCase: CasePath, timeout nanoseconds: UInt64? = nil, assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, @@ -1502,13 +1520,13 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { guard !self.reducer.inFlightEffects.isEmpty else { _ = { - self.receive(casePath, assert: updateStateToExpectedResult, file: file, line: line) + self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) }() return } await self.receiveAction(timeout: nanoseconds, file: file, line: line) _ = { - self.receive(casePath, assert: updateStateToExpectedResult, file: file, line: line) + self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) }() await Task.megaYield() } @@ -1516,9 +1534,9 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { #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. + /// This method is similar to ``receive(_:timeout:assert:file:line:)-4he05``, 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: @@ -1534,12 +1552,12 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { /// } /// ``` /// - /// 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. + /// 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: - /// - casePath: A case path identifying the case of an action to enum to receive + /// - 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 @@ -1549,7 +1567,7 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { @_disfavoredOverload @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) public func receive( - _ casePath: CasePath, + _ actionCase: CasePath, timeout duration: Duration, assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil, file: StaticString = #file, @@ -1558,13 +1576,13 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { guard !self.reducer.inFlightEffects.isEmpty else { _ = { - self.receive(casePath, assert: updateStateToExpectedResult, file: file, line: line) + self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) }() return } await self.receiveAction(timeout: duration.nanoseconds, file: file, line: line) _ = { - self.receive(casePath, assert: updateStateToExpectedResult, file: file, line: line) + self.receive(actionCase, assert: updateStateToExpectedResult, file: file, line: line) }() await Task.megaYield() } @@ -1573,16 +1591,14 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { private func receiveAction( matching predicate: (Action) -> Bool, failureMessage: @autoclosure () -> String, - onReceive: (Action) -> Void, + unexpectedActionDescription: (Action) -> String, _ updateStateToExpectedResult: ((inout ScopedState) throws -> Void)?, file: StaticString, line: UInt ) { guard !self.reducer.receivedActions.isEmpty else { XCTFail( - """ - Expected to receive an action, but received none. - """, + failureMessage(), file: file, line: line ) @@ -1626,7 +1642,17 @@ extension TestStore where ScopedState: Equatable, Action: Equatable { } let (receivedAction, state) = self.reducer.receivedActions.removeFirst() - onReceive(receivedAction) + if !predicate(receivedAction) { + XCTFailHelper( + """ + Received unexpected action: … + + \(unexpectedActionDescription(receivedAction)) + """, + file: file, + line: line + ) + } let expectedState = self.toScopedState(self.state) do { try self.expectedStateShouldMatch( @@ -1735,8 +1761,8 @@ extension TestStore { /// Useful for testing view store-specific state. /// /// - 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. + /// This state will be asserted against as it is mutated by the reducer. Useful for testing view + /// store state transformations. public func scope( state toScopedState: @escaping (ScopedState) -> S ) -> TestStore { @@ -1745,8 +1771,8 @@ extension TestStore { /// 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: + /// 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(/* ... */) @@ -1828,8 +1854,8 @@ extension TestStore { /// 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: + /// 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(/* ... */) @@ -1966,7 +1992,7 @@ extension TestStore { /// await store.send(.stopTimerButtonTapped).finish() /// ``` /// -/// See ``TestStore/finish(timeout:file:line:)-7pmv3`` for the ability to await all in-flight +/// See ``TestStore/finish(timeout:file:line:)-53gi5`` for the ability to await all in-flight /// effects in the test store. /// /// See ``ViewStoreTask`` for the analog provided to ``ViewStore``. @@ -1982,8 +2008,8 @@ public struct TestStoreTask: Hashable, Sendable { /// 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 + /// 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 diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 39aa73608..74414a2ea 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -489,11 +489,8 @@ public final class ViewStore { get: @escaping (ViewState) -> Value, send valueToAction: @escaping (Value) -> ViewAction ) -> Binding { - @ObservedState var val = get(self.state) - return .init( - get: { [$val] in $val.wrappedValue }, - set: { [weak self] in self?.send(valueToAction($0)) } - ) + ObservedObject(wrappedValue: self) + .projectedValue[get: .init(rawValue: get), send: .init(rawValue: valueToAction)] } /// Derives a binding from the store that prevents direct writes to state and instead sends /// actions to the store. @@ -585,6 +582,14 @@ public final class ViewStore { self.binding(send: { _ in action }) } #endif + + private subscript( + get state: HashableWrapper<(ViewState) -> Value>, + send action: HashableWrapper<(Value) -> ViewAction> + ) -> Value { + get { state.rawValue(self.state) } + set { self.send(action.rawValue(newValue)) } + } } /// A convenience type alias for referring to a view store of a given reducer's domain. @@ -728,35 +733,6 @@ public struct ViewStoreTask: Hashable, Sendable { #if canImport(Combine) extension ViewStore: ObservableObject { } - - final private class ValueWrapper: ObservableObject { - var value: V { - willSet { objectWillChange.send() } - } - - init(_ value: V) { - self.value = value - } - } - - @propertyWrapper private struct ObservedState: DynamicProperty { - @ObservedObject private var box: ValueWrapper - - var wrappedValue: Value { - get { box.value } - nonmutating set { box.value = newValue } - } - - var projectedValue: Binding { - .init( - get: { wrappedValue }, - set: { wrappedValue = $0 } - ) - } - init(wrappedValue value: Value) { - self._box = ObservedObject(wrappedValue: .init(value)) - } - } #endif /// A producer of store state. @@ -796,3 +772,9 @@ public struct StoreProducer: SignalProducerConvertible { self.upstream.map(keyPath).skipRepeats() } } + +private struct HashableWrapper: Hashable { + let rawValue: Value + static func == (lhs: Self, rhs: Self) -> Bool { false } + func hash(into hasher: inout Hasher) {} +} diff --git a/Tests/ComposableArchitectureTests/EffectCancellationTests.swift b/Tests/ComposableArchitectureTests/EffectCancellationTests.swift index bea884c1b..a2c8d536d 100644 --- a/Tests/ComposableArchitectureTests/EffectCancellationTests.swift +++ b/Tests/ComposableArchitectureTests/EffectCancellationTests.swift @@ -249,7 +249,7 @@ final class EffectCancellationTests: XCTestCase { } } .eraseToEffect() - .cancellable(id: 1) + .cancellable(id: id) for _ in 1...1_000 { effect = effect.cancellable(id: id) diff --git a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift index e51762726..9de515406 100644 --- a/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreFailureTests.swift @@ -217,7 +217,7 @@ XCTExpectFailure { store.receive(.action) } issueMatcher: { issue in - issue.compactDescription == "Expected to receive an action, but received none." + issue.compactDescription == #"Expected to receive an action "action", but didn't get one."# } } diff --git a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift index 448721988..b461f925c 100644 --- a/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift @@ -608,7 +608,7 @@ XCTExpectFailure { $0.compactDescription == """ - Expected to receive a matching action, but didn't get one. + Expected to receive an action matching case path, but didn't get one. """ } @@ -629,7 +629,7 @@ XCTExpectFailure { $0.compactDescription == """ - Expected to receive an action, but received none. + Expected to receive an action matching case path, but didn't get one. """ } diff --git a/Tests/ComposableArchitectureTests/TestStoreTests.swift b/Tests/ComposableArchitectureTests/TestStoreTests.swift index 922200538..3ac872096 100644 --- a/Tests/ComposableArchitectureTests/TestStoreTests.swift +++ b/Tests/ComposableArchitectureTests/TestStoreTests.swift @@ -168,6 +168,42 @@ import XCTest } } } + + func testReceiveActionMatchingPredicate() async { + enum Action: Equatable { + case noop, finished + } + + let reducer = Reduce { state, action in + switch action { + case .noop: + return EffectTask(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 func testStateAccess() async {