Skip to content

Commit 2be996a

Browse files
Effect dispatching multiple actions (#59)
* Testable ActionRecorder * Rename * Effect dispatching multiple * Add test * Update README.md * Add coverage fixing test
1 parent c932059 commit 2be996a

File tree

6 files changed

+100
-30
lines changed

6 files changed

+100
-30
lines changed

README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Fluxor can be installed as a dependency to your project using [Swift Package Man
5151

5252
### Requirements
5353

54-
- iOS 13.0+ / Mac OS X 10.15+ / tvOS 13.0+
54+
- iOS 13.0+ / macOS 10.15+ / tvOS 13.0+
5555
- Xcode 11.0+
5656
- Swift 5.2+
5757

@@ -106,7 +106,7 @@ import Fluxor
106106
import Foundation
107107

108108
class TodosEffects: Effects {
109-
let fetchTodos = Effect.dispatching {
109+
let fetchTodos = Effect.dispatchingOne {
110110
$0.ofType(FetchTodosAction.self)
111111
.flatMap { _ in
112112
Current.todoService.fetchTodos()

Sources/Fluxor/Effects.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import Combine
1313
*/
1414
public enum Effect {
1515
/// An `Effect` that publishes an `Action` to dispatch.
16-
case dispatching(_ publisher: (AnyPublisher<Action, Never>) -> AnyPublisher<Action, Never>)
16+
case dispatchingOne(_ publisher: (AnyPublisher<Action, Never>) -> AnyPublisher<Action, Never>)
17+
/// An `Effect` that publishes multiple`Action`s to dispatch.
18+
case dispatchingMultiple(_ publisher: (AnyPublisher<Action, Never>) -> AnyPublisher<[Action], Never>)
1719
/// An `Effect` that handles the action but doesn't publish a new `Action`.
1820
case nonDispatching(_ cancellable: (AnyPublisher<Action, Never>) -> AnyCancellable)
1921
}

Sources/Fluxor/Store.swift

+9-5
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,20 @@ open class Store<State: Encodable>: ObservableObject {
7878
*/
7979
public func register(effects: Effects) {
8080
effects.enabledEffects.forEach { effect in
81+
let cancellable: AnyCancellable
8182
switch effect {
82-
case .dispatching(let effectCreator):
83-
effectCreator(action.eraseToAnyPublisher())
83+
case .dispatchingOne(let effectCreator):
84+
cancellable = effectCreator(action.eraseToAnyPublisher())
8485
.receive(on: DispatchQueue.main)
8586
.sink(receiveValue: self.dispatch(action:))
86-
.store(in: &effectCancellables)
87+
case .dispatchingMultiple(let effectCreator):
88+
cancellable = effectCreator(action.eraseToAnyPublisher())
89+
.receive(on: DispatchQueue.main)
90+
.sink { $0.forEach(self.dispatch(action:)) }
8791
case .nonDispatching(let effectCreator):
88-
effectCreator(action.eraseToAnyPublisher())
89-
.store(in: &effectCancellables)
92+
cancellable = effectCreator(action.eraseToAnyPublisher())
9093
}
94+
cancellable.store(in: &effectCancellables)
9195
}
9296
}
9397

Sources/FluxorTestSupport/Effect+Run.swift

+25-13
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,33 @@ import Fluxor
1010
import XCTest
1111

1212
public extension Effect {
13+
enum RunError: Error {
14+
case wrongType
15+
}
16+
1317
/**
1418
Run the `Effect` with the specified `Action` and return the published `Action`s.
1519

1620
The `expectedCount` defines how many `Action`s the `Publisher` should publish before they are returned.
1721

1822
- Parameter action: The `Action` to send to the `Effect`
1923
- Parameter expectedCount: The count of `Action`s to wait for
20-
- Parameter file: The calling file (used in log if failing)
21-
- Parameter line: The calling line (used in log if failing)
2224
*/
23-
func run(with action: Action, expectedCount: Int = 1, file: StaticString = #file, line: UInt = #line) -> [Action] {
25+
func run(with action: Action, expectedCount: Int = 1) throws -> [Action] {
2426
let actions = PassthroughSubject<Action, Never>()
25-
guard case .dispatching(let effectCreator) = self else { return [] }
2627
let recorder = ActionRecorder(numberOfActions: expectedCount)
27-
effectCreator(actions.eraseToAnyPublisher()).subscribe(recorder)
28+
let publisher: AnyPublisher<[Action], Never>
29+
switch self {
30+
case .dispatchingOne(let effectCreator):
31+
publisher = effectCreator(actions.eraseToAnyPublisher()).map { [$0] }.eraseToAnyPublisher()
32+
case .dispatchingMultiple(let effectCreator):
33+
publisher = effectCreator(actions.eraseToAnyPublisher())
34+
case .nonDispatching:
35+
throw RunError.wrongType
36+
}
37+
publisher.subscribe(recorder)
2838
actions.send(action)
29-
recorder.waitForAllActions()
39+
try recorder.waitForAllActions()
3040
return recorder.actions
3141
}
3242

@@ -50,9 +60,13 @@ public extension Effect {
5060
Inspired by: https://vojtastavik.com/2019/12/11/combine-publisher-blocking-recorder/
5161
*/
5262
private class ActionRecorder {
53-
typealias Input = Action
63+
typealias Input = [Action]
5464
typealias Failure = Never
5565

66+
enum RecordingError: Error {
67+
case expectedCountNotReached(message: String)
68+
}
69+
5670
private let expectation = XCTestExpectation()
5771
private let waiter = XCTWaiter()
5872
private(set) var actions = [Action]() { didSet { expectation.fulfill() } }
@@ -65,10 +79,8 @@ private class ActionRecorder {
6579
Wait for all the expected `Action`s to be published.
6680

6781
- Parameter timeout: The time waiting for the `Action`s
68-
- Parameter file: The calling file (used in log if failing)
69-
- Parameter line: The calling line (used in log if failing)
7082
*/
71-
func waitForAllActions(timeout: TimeInterval = 1, file: StaticString = #file, line: UInt = #line) {
83+
func waitForAllActions(timeout: TimeInterval = 1) throws {
7284
guard actions.count < expectation.expectedFulfillmentCount else { return }
7385
let waitResult = waiter.wait(for: [expectation], timeout: timeout)
7486
if waitResult != .completed {
@@ -78,8 +90,8 @@ private class ActionRecorder {
7890
let formattedNumberOfActions = valueFormatter(expectation.expectedFulfillmentCount)
7991
let formattedActions = valueFormatter(actions.count)
8092

81-
XCTFail("Waiting for \(formattedNumberOfActions) timed out. Received only \(formattedActions).",
82-
file: file, line: line)
93+
let errorMessage = "Waiting for \(formattedNumberOfActions) timed out. Received only \(formattedActions)."
94+
throw RecordingError.expectedCountNotReached(message: errorMessage)
8395
}
8496
}
8597
}
@@ -91,7 +103,7 @@ extension ActionRecorder: Subscriber {
91103

92104
func receive(_ input: Input) -> Subscribers.Demand {
93105
DispatchQueue.main.async {
94-
self.actions.append(input)
106+
input.forEach { self.actions.append($0) }
95107
}
96108
return .unlimited
97109
}

Tests/FluxorTests/EffectsTests.swift

+54-5
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import XCTest
1313
class EffectsTests: XCTestCase {
1414
var action = PassthroughSubject<Action, Never>()
1515

16+
/// Can we lookup `Effect`s?
1617
func testEffectsLookup() {
1718
// Given
1819
struct TestEffects: Effects {
@@ -25,12 +26,13 @@ class EffectsTests: XCTestCase {
2526
XCTAssertEqual(testEffects.enabledEffects.count, 1)
2627
}
2728

28-
func testEffectRunDispatching() {
29+
/// Can we run a single dispatching `Effect`?
30+
func testEffectRunDispatchingOne() throws {
2931
// Given
3032
let action2 = Test2Action()
3133
let expectation = XCTestExpectation(description: debugDescription)
3234
expectation.expectedFulfillmentCount = 1
33-
let effect = Effect.dispatching {
35+
let effect = Effect.dispatchingOne {
3436
$0.ofType(Test1Action.self)
3537
.map { _ in
3638
expectation.fulfill()
@@ -40,14 +42,41 @@ class EffectsTests: XCTestCase {
4042
}
4143
// When
4244
let action = Test1Action()
43-
let actions: [Action] = effect.run(with: action)
45+
let actions: [Action] = try effect.run(with: action)
4446
effect.run(with: action) // Returns early because of wrong type
4547
// Then
4648
wait(for: [expectation], timeout: 1)
4749
XCTAssertEqual(actions[0] as! Test2Action, action2)
50+
XCTAssertThrowsError(try effect.run(with: action, expectedCount: 2))
4851
}
4952

50-
func testEffectRunNonDispatching() {
53+
/// Can we run a multi dispatching `Effect`?
54+
func testEffectRunDispatchingMultiple() throws {
55+
// Given
56+
let action2 = Test2Action()
57+
let action3 = Test3Action()
58+
let expectation = XCTestExpectation(description: debugDescription)
59+
expectation.expectedFulfillmentCount = 1
60+
let effect = Effect.dispatchingMultiple {
61+
$0.ofType(Test1Action.self)
62+
.map { _ in
63+
expectation.fulfill()
64+
return [action2, action3]
65+
}
66+
.eraseToAnyPublisher()
67+
}
68+
// When
69+
let action = Test1Action()
70+
let actions: [Action] = try effect.run(with: action, expectedCount: 2)
71+
effect.run(with: action) // Returns early because of wrong type
72+
// Then
73+
wait(for: [expectation], timeout: 1)
74+
XCTAssertEqual(actions[0] as! Test2Action, action2)
75+
XCTAssertEqual(actions[1] as! Test3Action, action3)
76+
}
77+
78+
/// Can we run a non dispatching `Effect`?
79+
func testEffectRunNonDispatching() throws {
5180
// Given
5281
let expectation = XCTestExpectation(description: debugDescription)
5382
expectation.expectedFulfillmentCount = 1
@@ -57,13 +86,33 @@ class EffectsTests: XCTestCase {
5786
// When
5887
let action = Test1Action()
5988
effect.run(with: action)
60-
_ = effect.run(with: action) // Returns early because of wrong type
89+
XCTAssertThrowsError(try effect.run(with: action, expectedCount: 1)) // Returns early because of wrong type
6190
// Then
6291
wait(for: [expectation], timeout: 1)
6392
}
93+
94+
/// Only here for test coverage of ActionRecorder's empty completion function.
95+
func testActionRecorderCompletion() throws {
96+
var cancellable: AnyCancellable!
97+
let effect = Effect.dispatchingOne {
98+
let publisher = PassthroughSubject<Action, Never>()
99+
cancellable = $0.sink {
100+
publisher.send($0)
101+
publisher.send(completion: .finished)
102+
}
103+
return publisher.eraseToAnyPublisher()
104+
}
105+
_ = try effect.run(with: Test1Action(), expectedCount: 1)
106+
XCTAssertNotNil(cancellable)
107+
}
64108
}
65109

66110
private struct Test1Action: Action {}
111+
67112
private struct Test2Action: Action, Equatable {
68113
let id = UUID()
69114
}
115+
116+
private struct Test3Action: Action, Equatable {
117+
let id = UUID()
118+
}

Tests/FluxorTests/StoreTests.swift

+7-4
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,11 @@ class StoreTests: XCTestCase {
5151
// Then
5252
wait(for: [TestEffects.expectation], timeout: 1)
5353
let dispatchedActions = interceptor.stateChanges.map(\.action)
54-
XCTAssertEqual(dispatchedActions.count, 3)
54+
XCTAssertEqual(dispatchedActions.count, 4)
5555
XCTAssertEqual(dispatchedActions[0] as! TestAction, firstAction)
5656
XCTAssertEqual(dispatchedActions[1] as! AnonymousAction<Void>, TestEffects.responseAction)
5757
XCTAssertEqual(dispatchedActions[2] as! AnonymousAction<Int>, TestEffects.generateAction)
58+
XCTAssertEqual(dispatchedActions[3] as! AnonymousAction<Void>, TestEffects.unrelatedAction)
5859
XCTAssertEqual(TestEffects.lastAction, TestEffects.generateAction)
5960
}
6061

@@ -179,21 +180,23 @@ class StoreTests: XCTestCase {
179180
static let responseAction = TestEffects.responseActionTemplate.createAction()
180181
static let generateActionTemplate = ActionTemplate(id: "TestGenerateAction", payloadType: Int.self)
181182
static let generateAction = TestEffects.generateActionTemplate.createAction(payload: 42)
183+
static let unrelatedActionTemplate = ActionTemplate(id: "UnrelatedAction")
184+
static let unrelatedAction = TestEffects.unrelatedActionTemplate.createAction()
182185
static let expectation = XCTestExpectation()
183186
static var lastAction: AnonymousAction<Int>?
184187
static var threadCheck: (() -> Void)!
185188

186-
let testEffect = Effect.dispatching {
189+
let testEffect = Effect.dispatchingOne {
187190
$0.ofType(TestAction.self)
188191
.receive(on: DispatchQueue.global(qos: .background))
189192
.map { _ in TestEffects.responseAction }
190193
.eraseToAnyPublisher()
191194
}
192195

193-
let anotherTestEffect = Effect.dispatching {
196+
let anotherTestEffect = Effect.dispatchingMultiple {
194197
$0.withIdentifier(TestEffects.responseActionIdentifier)
195198
.handleEvents(receiveOutput: { _ in TestEffects.threadCheck() })
196-
.map { _ in TestEffects.generateAction }
199+
.map { _ in [TestEffects.generateAction, TestEffects.unrelatedAction] }
197200
.eraseToAnyPublisher()
198201
}
199202

0 commit comments

Comments
 (0)