Skip to content

Commit b6bc66e

Browse files
stephencelismbrandonw
authored andcommitted
Unconstrain TestStore action for predicate/case path receive (#1856)
* Unconstrain TestStore action for predicate/case path `receive` These methods are currently defined in a constrained extension, but it's not necessary, so let's loosen the constraint. * added some tests * Update Sources/ComposableArchitecture/TestStore.swift * flakey test Co-authored-by: Brandon Williams <[email protected]> (cherry picked from commit b524b01be3dd7aa0220a4184cc1500ee7cb62ece)
1 parent ba270a8 commit b6bc66e

File tree

4 files changed

+175
-128
lines changed

4 files changed

+175
-128
lines changed

Sources/ComposableArchitecture/TestStore.swift

Lines changed: 107 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,111 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
13371337
)
13381338
}
13391339

1340+
// NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library.
1341+
// See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15
1342+
#if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst)
1343+
/// Asserts an action was received from an effect and asserts how the state changes.
1344+
///
1345+
/// When an effect is executed in your feature and sends an action back into the system, you can
1346+
/// use this method to assert that fact, and further assert how state changes after the effect
1347+
/// action is received:
1348+
///
1349+
/// ```swift
1350+
/// await store.send(.buttonTapped)
1351+
/// await store.receive(.response(.success(42)) {
1352+
/// $0.count = 42
1353+
/// }
1354+
/// ```
1355+
///
1356+
/// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to
1357+
/// pass before effects execute and send actions, and that is why this method suspends. The
1358+
/// default time waited is very small, and typically it is enough so you should be controlling
1359+
/// your dependencies so that they do not wait for real world time to pass (see
1360+
/// <doc:DependencyManagement> for more information on how to do that).
1361+
///
1362+
/// To change the amount of time this method waits for an action, pass an explicit `timeout`
1363+
/// argument, or set the ``timeout`` on the ``TestStore``.
1364+
///
1365+
/// - Parameters:
1366+
/// - expectedAction: An action expected from an effect.
1367+
/// - duration: The amount of time to wait for the expected action.
1368+
/// - updateStateToExpectedResult: A closure that asserts state changed by sending the action
1369+
/// to the store. The mutable state sent to this closure must be modified to match the state
1370+
/// of the store after processing the given action. Do not provide a closure if no change
1371+
/// is expected.
1372+
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
1373+
@MainActor
1374+
public func receive(
1375+
_ expectedAction: Action,
1376+
timeout duration: Duration,
1377+
assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil,
1378+
file: StaticString = #file,
1379+
line: UInt = #line
1380+
) async {
1381+
await self.receive(
1382+
expectedAction,
1383+
timeout: duration.nanoseconds,
1384+
assert: updateStateToExpectedResult,
1385+
file: file,
1386+
line: line
1387+
)
1388+
}
1389+
#endif
1390+
1391+
/// Asserts an action was received from an effect and asserts how the state changes.
1392+
///
1393+
/// When an effect is executed in your feature and sends an action back into the system, you can
1394+
/// use this method to assert that fact, and further assert how state changes after the effect
1395+
/// action is received:
1396+
///
1397+
/// ```swift
1398+
/// await store.send(.buttonTapped)
1399+
/// await store.receive(.response(.success(42)) {
1400+
/// $0.count = 42
1401+
/// }
1402+
/// ```
1403+
///
1404+
/// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to pass
1405+
/// before effects execute and send actions, and that is why this method suspends. The default
1406+
/// time waited is very small, and typically it is enough so you should be controlling your
1407+
/// dependencies so that they do not wait for real world time to pass (see
1408+
/// <doc:DependencyManagement> for more information on how to do that).
1409+
///
1410+
/// To change the amount of time this method waits for an action, pass an explicit `timeout`
1411+
/// argument, or set the ``timeout`` on the ``TestStore``.
1412+
///
1413+
/// - Parameters:
1414+
/// - expectedAction: An action expected from an effect.
1415+
/// - nanoseconds: The amount of time to wait for the expected action.
1416+
/// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to
1417+
/// the store. The mutable state sent to this closure must be modified to match the state of
1418+
/// the store after processing the given action. Do not provide a closure if no change is
1419+
/// expected.
1420+
@MainActor
1421+
@_disfavoredOverload
1422+
public func receive(
1423+
_ expectedAction: Action,
1424+
timeout nanoseconds: UInt64? = nil,
1425+
assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil,
1426+
file: StaticString = #file,
1427+
line: UInt = #line
1428+
) async {
1429+
guard !self.reducer.inFlightEffects.isEmpty
1430+
else {
1431+
_ = {
1432+
self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line)
1433+
}()
1434+
return
1435+
}
1436+
await self.receiveAction(timeout: nanoseconds, file: file, line: line)
1437+
_ = {
1438+
self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line)
1439+
}()
1440+
await Task.megaYield()
1441+
}
1442+
}
1443+
1444+
extension TestStore where ScopedState: Equatable {
13401445
/// Asserts a matching action was received from an effect and asserts how the state changes.
13411446
///
13421447
/// See ``receive(_:timeout:assert:file:line:)-3myco`` for more information of how to use this
@@ -1412,53 +1517,6 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
14121517
// NB: Only needed until Xcode ships a macOS SDK that uses the 5.7 standard library.
14131518
// See: https://forums.swift.org/t/xcode-14-rc-cannot-specialize-protocol-type/60171/15
14141519
#if swift(>=5.7) && !os(macOS) && !targetEnvironment(macCatalyst)
1415-
/// Asserts an action was received from an effect and asserts how the state changes.
1416-
///
1417-
/// When an effect is executed in your feature and sends an action back into the system, you can
1418-
/// use this method to assert that fact, and further assert how state changes after the effect
1419-
/// action is received:
1420-
///
1421-
/// ```swift
1422-
/// await store.send(.buttonTapped)
1423-
/// await store.receive(.response(.success(42)) {
1424-
/// $0.count = 42
1425-
/// }
1426-
/// ```
1427-
///
1428-
/// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to
1429-
/// pass before effects execute and send actions, and that is why this method suspends. The
1430-
/// default time waited is very small, and typically it is enough so you should be controlling
1431-
/// your dependencies so that they do not wait for real world time to pass (see
1432-
/// <doc:DependencyManagement> for more information on how to do that).
1433-
///
1434-
/// To change the amount of time this method waits for an action, pass an explicit `timeout`
1435-
/// argument, or set the ``timeout`` on the ``TestStore``.
1436-
///
1437-
/// - Parameters:
1438-
/// - expectedAction: An action expected from an effect.
1439-
/// - duration: The amount of time to wait for the expected action.
1440-
/// - updateStateToExpectedResult: A closure that asserts state changed by sending the action
1441-
/// to the store. The mutable state sent to this closure must be modified to match the state
1442-
/// of the store after processing the given action. Do not provide a closure if no change
1443-
/// is expected.
1444-
@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
1445-
@MainActor
1446-
public func receive(
1447-
_ expectedAction: Action,
1448-
timeout duration: Duration,
1449-
assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil,
1450-
file: StaticString = #file,
1451-
line: UInt = #line
1452-
) async {
1453-
await self.receive(
1454-
expectedAction,
1455-
timeout: duration.nanoseconds,
1456-
assert: updateStateToExpectedResult,
1457-
file: file,
1458-
line: line
1459-
)
1460-
}
1461-
14621520
/// Asserts an action was received from an effect that matches a predicate, and asserts how the
14631521
/// state changes.
14641522
///
@@ -1511,58 +1569,6 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
15111569
}
15121570
#endif
15131571

1514-
/// Asserts an action was received from an effect and asserts how the state changes.
1515-
///
1516-
/// When an effect is executed in your feature and sends an action back into the system, you can
1517-
/// use this method to assert that fact, and further assert how state changes after the effect
1518-
/// action is received:
1519-
///
1520-
/// ```swift
1521-
/// await store.send(.buttonTapped)
1522-
/// await store.receive(.response(.success(42)) {
1523-
/// $0.count = 42
1524-
/// }
1525-
/// ```
1526-
///
1527-
/// Due to the variability of concurrency in Swift, sometimes a small amount of time needs to pass
1528-
/// before effects execute and send actions, and that is why this method suspends. The default
1529-
/// time waited is very small, and typically it is enough so you should be controlling your
1530-
/// dependencies so that they do not wait for real world time to pass (see
1531-
/// <doc:DependencyManagement> for more information on how to do that).
1532-
///
1533-
/// To change the amount of time this method waits for an action, pass an explicit `timeout`
1534-
/// argument, or set the ``timeout`` on the ``TestStore``.
1535-
///
1536-
/// - Parameters:
1537-
/// - expectedAction: An action expected from an effect.
1538-
/// - nanoseconds: The amount of time to wait for the expected action.
1539-
/// - updateStateToExpectedResult: A closure that asserts state changed by sending the action to
1540-
/// the store. The mutable state sent to this closure must be modified to match the state of
1541-
/// the store after processing the given action. Do not provide a closure if no change is
1542-
/// expected.
1543-
@MainActor
1544-
@_disfavoredOverload
1545-
public func receive(
1546-
_ expectedAction: Action,
1547-
timeout nanoseconds: UInt64? = nil,
1548-
assert updateStateToExpectedResult: ((inout ScopedState) throws -> Void)? = nil,
1549-
file: StaticString = #file,
1550-
line: UInt = #line
1551-
) async {
1552-
guard !self.reducer.inFlightEffects.isEmpty
1553-
else {
1554-
_ = {
1555-
self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line)
1556-
}()
1557-
return
1558-
}
1559-
await self.receiveAction(timeout: nanoseconds, file: file, line: line)
1560-
_ = {
1561-
self.receive(expectedAction, assert: updateStateToExpectedResult, file: file, line: line)
1562-
}()
1563-
await Task.megaYield()
1564-
}
1565-
15661572
/// Asserts an action was received from an effect that matches a predicate, and asserts how the
15671573
/// state changes.
15681574
///
@@ -1760,10 +1766,9 @@ extension TestStore where ScopedState: Equatable, Action: Equatable {
17601766
while let receivedAction = self.reducer.receivedActions.first,
17611767
!predicate(receivedAction.action)
17621768
{
1769+
self.reducer.receivedActions.removeFirst()
17631770
actions.append(receivedAction.action)
1764-
self.withExhaustivity(.off) {
1765-
self.receive(receivedAction.action, file: file, line: line)
1766-
}
1771+
self.reducer.state = receivedAction.state
17671772
}
17681773

17691774
if !actions.isEmpty {

Sources/ComposableArchitecture/ViewStore.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,7 @@ public final class ViewStore<ViewState, ViewAction> {
395395
/// - Parameters:
396396
/// - action: An action.
397397
/// - predicate: A predicate on `ViewState` that determines for how long this method should
398-
/// suspend.
398+
/// suspend.
399399
@MainActor
400400
public func send(_ action: ViewAction, while predicate: @escaping (ViewState) -> Bool) async {
401401
let task = self.send(action)
@@ -415,7 +415,7 @@ public final class ViewStore<ViewState, ViewAction> {
415415
/// - action: An action.
416416
/// - animation: The animation to perform when the action is sent.
417417
/// - predicate: A predicate on `ViewState` that determines for how long this method should
418-
/// suspend.
418+
/// suspend.
419419
@MainActor
420420
public func send(
421421
_ action: ViewAction,
@@ -437,7 +437,7 @@ public final class ViewStore<ViewState, ViewAction> {
437437
/// ``send(_:while:)``.
438438
///
439439
/// - Parameter predicate: A predicate on `ViewState` that determines for how long this method
440-
/// should suspend.
440+
/// should suspend.
441441
@MainActor
442442
public func yield(while predicate: @escaping (ViewState) -> Bool) async {
443443
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {

Tests/ComposableArchitectureTests/EffectTests.swift

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -291,33 +291,35 @@ final class EffectTests: XCTestCase {
291291
}
292292

293293
func testDependenciesTransferredToEffects_Run() async {
294-
struct Feature: ReducerProtocol {
295-
enum Action: Equatable {
296-
case tap
297-
case response(Int)
298-
}
299-
@Dependency(\.date) var date
300-
func reduce(into state: inout Int, action: Action) -> EffectTask<Action> {
301-
switch action {
302-
case .tap:
303-
return .run { send in
304-
await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate)))
294+
await _withMainSerialExecutor {
295+
struct Feature: ReducerProtocol {
296+
enum Action: Equatable {
297+
case tap
298+
case response(Int)
299+
}
300+
@Dependency(\.date) var date
301+
func reduce(into state: inout Int, action: Action) -> EffectTask<Action> {
302+
switch action {
303+
case .tap:
304+
return .run { send in
305+
await send(.response(Int(self.date.now.timeIntervalSinceReferenceDate)))
306+
}
307+
case let .response(value):
308+
state = value
309+
return .none
305310
}
306-
case let .response(value):
307-
state = value
308-
return .none
309311
}
310312
}
311-
}
312-
let store = TestStore(
313-
initialState: 0,
314-
reducer: Feature()
315-
.dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890)))
316-
)
313+
let store = TestStore(
314+
initialState: 0,
315+
reducer: Feature()
316+
.dependency(\.date, .constant(.init(timeIntervalSinceReferenceDate: 1_234_567_890)))
317+
)
317318

318-
await store.send(.tap).finish(timeout: NSEC_PER_SEC)
319-
await store.receive(.response(1_234_567_890)) {
320-
$0 = 1_234_567_890
319+
await store.send(.tap).finish(timeout: NSEC_PER_SEC)
320+
await store.receive(.response(1_234_567_890)) {
321+
$0 = 1_234_567_890
322+
}
321323
}
322324
}
323325

Tests/ComposableArchitectureTests/TestStoreNonExhaustiveTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,46 @@
584584
}
585585
}
586586

587+
func testCasePathReceive_Exhaustive_NonEquatable() async {
588+
struct NonEquatable {}
589+
enum Action { case tap, response(NonEquatable) }
590+
591+
let store = TestStore(
592+
initialState: 0,
593+
reducer: Reduce<Int, Action> { state, action in
594+
switch action {
595+
case .tap:
596+
return EffectTask(value: .response(NonEquatable()))
597+
case .response:
598+
return .none
599+
}
600+
}
601+
)
602+
603+
await store.send(.tap)
604+
await store.receive(/Action.response)
605+
}
606+
607+
func testPredicateReceive_Exhaustive_NonEquatable() async {
608+
struct NonEquatable {}
609+
enum Action { case tap, response(NonEquatable) }
610+
611+
let store = TestStore(
612+
initialState: 0,
613+
reducer: Reduce<Int, Action> { state, action in
614+
switch action {
615+
case .tap:
616+
return EffectTask(value: .response(NonEquatable()))
617+
case .response:
618+
return .none
619+
}
620+
}
621+
)
622+
623+
await store.send(.tap)
624+
await store.receive({ (/Action.response) ~= $0 })
625+
}
626+
587627
func testCasePathReceive_SkipReceivedAction() async {
588628
let store = TestStore(
589629
initialState: NonExhaustiveReceive.State(),

0 commit comments

Comments
 (0)