Skip to content

Unconstrain TestStore action for predicate/case path receive #1856

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jan 23, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 107 additions & 102 deletions Sources/ComposableArchitecture/TestStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1203,6 +1203,111 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
)
}

// 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(.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
/// <doc:DependencyManagement> 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
)
}
#endif

/// 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(.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
/// <doc:DependencyManagement> 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.
/// - 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()
}
}

extension TestStore where ScopedState: Equatable {
/// Asserts a matching action was received from an effect and asserts how the state changes.
///
/// See ``receive(_:timeout:assert:file:line:)-3myco`` for more information of how to use this
Expand Down Expand Up @@ -1278,53 +1383,6 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
// 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(.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
/// <doc:DependencyManagement> 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.
///
Expand Down Expand Up @@ -1377,58 +1435,6 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
}
#endif

/// 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(.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
/// <doc:DependencyManagement> 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.
/// - 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 an action was received from an effect that matches a predicate, and asserts how the
/// state changes.
///
Expand Down Expand Up @@ -1626,10 +1632,9 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
while let receivedAction = self.reducer.receivedActions.first,
!predicate(receivedAction.action)
{
self.reducer.receivedActions.removeFirst()
actions.append(receivedAction.action)
self.withExhaustivity(.off) {
self.receive(receivedAction.action, file: file, line: line)
}
self.reducer.state = receivedAction.state
}

if !actions.isEmpty {
Expand Down
6 changes: 3 additions & 3 deletions Sources/ComposableArchitecture/ViewStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ public final class ViewStore<ViewState, ViewAction>: ObservableObject {
/// - Parameters:
/// - action: An action.
/// - predicate: A predicate on `ViewState` that determines for how long this method should
/// suspend.
/// suspend.
@MainActor
public func send(_ action: ViewAction, while predicate: @escaping (ViewState) -> Bool) async {
let task = self.send(action)
Expand All @@ -396,7 +396,7 @@ public final class ViewStore<ViewState, ViewAction>: ObservableObject {
/// - action: An action.
/// - animation: The animation to perform when the action is sent.
/// - predicate: A predicate on `ViewState` that determines for how long this method should
/// suspend.
/// suspend.
@MainActor
public func send(
_ action: ViewAction,
Expand All @@ -417,7 +417,7 @@ public final class ViewStore<ViewState, ViewAction>: ObservableObject {
/// ``send(_:while:)``.
///
/// - Parameter predicate: A predicate on `ViewState` that determines for how long this method
/// should suspend.
/// should suspend.
@MainActor
public func yield(while predicate: @escaping (ViewState) -> Bool) async {
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
Expand Down
48 changes: 25 additions & 23 deletions Tests/ComposableArchitectureTests/EffectTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,33 +307,35 @@ final class EffectTests: XCTestCase {
}

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) -> EffectTask<Action> {
switch action {
case .tap:
return .run { send in
await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate)))
await _withMainSerialExecutor {
struct Feature: ReducerProtocol {
enum Action: Equatable {
case tap
case response(Int)
}
@Dependency(\.date) var date
func reduce(into state: inout Int, action: Action) -> EffectTask<Action> {
switch action {
case .tap:
return .run { send in
await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate)))
}
case let .response(value):
state = value
return .none
}
case let .response(value):
state = value
return .none
}
}
}
let store = TestStore(
initialState: 0,
reducer: Feature()
.dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890)))
)
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
await store.send(.tap).finish(timeout: NSEC_PER_SEC)
await store.receive(.response(1_234_567_890)) {
$0 = 1_234_567_890
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,46 @@
}
}

func testCasePathReceive_Exhaustive_NonEquatable() async {
struct NonEquatable {}
enum Action { case tap, response(NonEquatable) }

let store = TestStore(
initialState: 0,
reducer: Reduce<Int, Action> { state, action in
switch action {
case .tap:
return EffectTask(value: .response(NonEquatable()))
case .response:
return .none
}
}
)

await store.send(.tap)
await store.receive(/Action.response)
}

func testPredicateReceive_Exhaustive_NonEquatable() async {
struct NonEquatable {}
enum Action { case tap, response(NonEquatable) }

let store = TestStore(
initialState: 0,
reducer: Reduce<Int, Action> { state, action in
switch action {
case .tap:
return EffectTask(value: .response(NonEquatable()))
case .response:
return .none
}
}
)

await store.send(.tap)
await store.receive({ (/Action.response) ~= $0 })
}

func testCasePathReceive_SkipReceivedAction() async {
let store = TestStore(
initialState: NonExhaustiveReceive.State(),
Expand Down