diff --git a/Sources/Fluxor/Effects.swift b/Sources/Fluxor/Effects.swift index ccce11c..7540b05 100644 --- a/Sources/Fluxor/Effects.swift +++ b/Sources/Fluxor/Effects.swift @@ -28,10 +28,14 @@ public protocol Effects { associatedtype Environment /// The `Effect`s to register on the `Store`. var enabledEffects: [Effect] { get } + /// The identifier for the `Effects` + static var id: String { get } } public extension Effects { var enabledEffects: [Effect] { Mirror(reflecting: self).children.compactMap { $0.value as? Effect } } + + static var id: String { .init(describing: Self.self) } } diff --git a/Sources/Fluxor/Interceptor.swift b/Sources/Fluxor/Interceptor.swift index 3de3a0b..1bc390d 100644 --- a/Sources/Fluxor/Interceptor.swift +++ b/Sources/Fluxor/Interceptor.swift @@ -16,13 +16,21 @@ public protocol Interceptor { - Parameter newState: The `State` after the `Action` was dispatched */ func actionDispatched(action: Action, oldState: State, newState: State) + /// The identifier for the `Interceptor` + static var id: String { get } +} + +public extension Interceptor { + static var id: String { .init(describing: self) } } /// A type-erased `Interceptor` used to store all `Interceptor`s in an array in the `Store`. internal struct AnyInterceptor: Interceptor { + let originalId: String private let _actionDispatched: (Action, State, State) -> Void init(_ interceptor: I) where I.State == State { + originalId = type(of: interceptor).id _actionDispatched = interceptor.actionDispatched } diff --git a/Sources/Fluxor/Reducer.swift b/Sources/Fluxor/Reducer.swift index 22c3380..b706ec0 100644 --- a/Sources/Fluxor/Reducer.swift +++ b/Sources/Fluxor/Reducer.swift @@ -4,21 +4,26 @@ * MIT license, see LICENSE file for details */ +import struct Foundation.UUID + /// A type which takes a `State` and `Action` returns a new `State`. public struct Reducer { + /// An unique identifier used when registering/unregistering the `Reducer` on the `Store`. + public let id: String /// A pure function which takes the a `State` and an `Action` and returns a new `State`. public let reduce: (inout State, Action) -> Void /** Creates a `Reducer` from a `reduce` function. - + The `reduce` function is a pure function which takes the a `State` and an `Action` and returns a new `State`. - Parameter reduce: The `reduce` function to create a `Reducer` from - Parameter state: The `State` to mutate - Parameter action: The `Action` dispatched */ - public init(reduce: @escaping (_ state: inout State, _ action: Action) -> Void) { + public init(id: String = UUID().uuidString, reduce: @escaping (_ state: inout State, _ action: Action) -> Void) { + self.id = id self.reduce = reduce } @@ -27,7 +32,8 @@ public struct Reducer { - Parameter reduceOns: The `ReduceOn`s which the created `Reducer` should contain */ - public init(_ reduceOns: ReduceOn...) { + public init(id: String = UUID().uuidString, _ reduceOns: ReduceOn...) { + self.id = id self.reduce = { state, action in reduceOns.forEach { $0.reduce(&state, action) } } diff --git a/Sources/Fluxor/Store.swift b/Sources/Fluxor/Store.swift index 17e278e..19b7f32 100644 --- a/Sources/Fluxor/Store.swift +++ b/Sources/Fluxor/Store.swift @@ -30,9 +30,11 @@ open class Store: ObservableObject { private let actions = PassthroughSubject() private let environment: Environment private var reducers = [KeyedReducer]() - private var effectCancellables = Set() + private var effects = [String: [AnyCancellable]]() private var interceptors = [AnyInterceptor]() + // MARK: - Initialization + /** Initializes the `Store` with an initial `State`, an `Environment` and eventually `Reducer`s. @@ -47,6 +49,8 @@ open class Store: ObservableObject { reducers.forEach(register(reducer:)) } + // MARK: - Dispatching + /** Dispatches an `Action` and creates a new `State` by running the current `State` and the `Action` through all registered `Reducer`s. @@ -65,6 +69,8 @@ open class Store: ObservableObject { actions.send(action) } + // MARK: - Reducers + /** Registers the given `Reducer`. The `Reducer` will be run for all subsequent actions. @@ -84,13 +90,24 @@ open class Store: ObservableObject { reducers.append(KeyedReducer(keyPath: keyPath, reducer: reducer)) } + /** + Unregisters the given `Reducer`. The `Reducer` will no longer be run when `Action`s are dispatched. + + - Parameter reducer: The `Reducer` to unregister + */ + public func unregister(reducer: Reducer) { + reducers.removeAll { $0.id == reducer.id } + } + + // MARK: - Effects + /** Registers the given `Effects`. The `Effects` will receive all subsequent actions. - Parameter effects: The `Effects` to register */ public func register(effects: E) where E.Environment == Environment { - register(effects: effects.enabledEffects) + self.effects[type(of: effects).id] = createCancellables(for: effects.enabledEffects) } /** @@ -105,29 +122,27 @@ open class Store: ObservableObject { /** Registers the given `Effect`. The `Effect` will receive all subsequent actions. + Only `Effect`s registered from a type conforming to `Effects` can be unregistered. + - Parameter effect: The `Effect` to register */ public func register(effect: Effect) { - let cancellable: AnyCancellable - switch effect { - case .dispatchingOne(let effectCreator): - cancellable = effectCreator(actions.eraseToAnyPublisher(), environment) - .receive(on: DispatchQueue.main) - .sink(receiveValue: self.dispatch(action:)) - case .dispatchingMultiple(let effectCreator): - cancellable = effectCreator(actions.eraseToAnyPublisher(), environment) - .receive(on: DispatchQueue.main) - .sink { $0.forEach(self.dispatch(action:)) } - case .nonDispatching(let effectCreator): - cancellable = effectCreator(actions.eraseToAnyPublisher(), environment) - } - cancellable.store(in: &effectCancellables) + self.effects["*"] = (self.effects["*"] ?? []) + [createCancellable(for: effect)] } /** - Registers the given `Interceptor`. The `Interceptor` will receive all subsequent `Action`s and state changes. + Unregisters the given `Effects`. The `Effects` will no longer receive any actions. + + - Parameter effects: The `Effects` to register + */ + public func unregisterEffects(ofType effects: E.Type) where E.Environment == Environment { + self.effects.removeValue(forKey: effects.id) // An AnyCancellable instance calls cancel() when deinitialized + } - The associated type `State` on the `Interceptor` must match the generic `State` on the `Store`. + // MARK: - Interceptors + + /** + Registers the given `Interceptor`. The `Interceptor` will receive all subsequent `Action`s and state changes. - Parameter interceptor: The `Interceptor` to register */ @@ -135,6 +150,19 @@ open class Store: ObservableObject { interceptors.append(AnyInterceptor(interceptor)) } + /** + Unregisters all registered `Interceptor`s of the given type. + The `Interceptor`s will no longer receive any `Action`s or state changes. + + - Parameter interceptor: The type of`Interceptor` to unregister + */ + + public func unregisterInterceptors(ofType interceptor: I.Type) where I.State == State { + interceptors.removeAll { $0.originalId == interceptor.id } + } + + // MARK: - Selecting + /** Creates a `Publisher` for a `Selector`. @@ -156,6 +184,8 @@ open class Store: ObservableObject { } } +// MARK: - Void Environment + public extension Store where Environment == Void { /** Initializes the `Store` with an initial `State` and eventually `Reducer`s. @@ -170,10 +200,48 @@ public extension Store where Environment == Void { } } +// MARK: - Private + +extension Store { + /** + Creates `Cancellable`s for the given `Effect`s. + + - Parameter effects: The `Effect`s to create `Cancellable`s for + - Returns: The `Cancellable`s for the given `Effect`s + */ + private func createCancellables(for effects: [Effect]) -> [AnyCancellable] { + return effects.map(createCancellable(for:)) + } + + /** + Creates `Cancellable` for the given `Effect`. + + - Parameter effect: The `Effect` to create `Cancellable` for + - Returns: The `Cancellable` for the given `Effect` + */ + private func createCancellable(for effect: Effect) -> AnyCancellable { + switch effect { + case .dispatchingOne(let effectCreator): + return effectCreator(actions.eraseToAnyPublisher(), environment) + .receive(on: DispatchQueue.main) + .sink(receiveValue: self.dispatch(action:)) + case .dispatchingMultiple(let effectCreator): + return effectCreator(actions.eraseToAnyPublisher(), environment) + .receive(on: DispatchQueue.main) + .sink { $0.forEach(self.dispatch(action:)) } + case .nonDispatching(let effectCreator): + return effectCreator(actions.eraseToAnyPublisher(), environment) + } + } +} + +/// A wrapper for a `Reducer` for a specific `KeyPath`. private struct KeyedReducer { + let id: String let reduce: (inout State, Action) -> Void init(keyPath: WritableKeyPath, reducer: Reducer) { + self.id = reducer.id self.reduce = { state, action in var substate = state[keyPath: keyPath] reducer.reduce(&substate, action) diff --git a/Sources/FluxorTestSupport/TestInterceptor.swift b/Sources/FluxorTestSupport/TestInterceptor.swift index 9d32ac4..8f0d6ed 100644 --- a/Sources/FluxorTestSupport/TestInterceptor.swift +++ b/Sources/FluxorTestSupport/TestInterceptor.swift @@ -25,6 +25,13 @@ public class TestInterceptor: Interceptor { stateChanges.append((action, oldState, newState)) } + /** + Waits for the expected number of `Action`s to be intercepted. + If the expected number of `Action`s are not intercepted before the timout an error is thrown. + + - Parameter expectedNumberOfActions: The number of `Action`s to wait for + - Parameter timeout: The waiting time before failing (in seconds) + */ public func waitForActions(expectedNumberOfActions: Int, timeout: TimeInterval = 1) throws { guard stateChanges.count < expectedNumberOfActions else { return } @@ -41,6 +48,7 @@ public class TestInterceptor: Interceptor { throw WaitingError.expectedCountNotReached(message: errorMessage) } + /// Errors waiting for intercepted `Action`s public enum WaitingError: Error { case expectedCountNotReached(message: String) } diff --git a/Tests/FluxorTests/StoreTests.swift b/Tests/FluxorTests/StoreTests.swift index 73e2abe..b304a31 100644 --- a/Tests/FluxorTests/StoreTests.swift +++ b/Tests/FluxorTests/StoreTests.swift @@ -43,21 +43,37 @@ class StoreTests: XCTestCase { func testRegisteringSubstateReducers() { // Given let incrementActionTemplate = ActionTemplate(id: "Increment", payloadType: Int.self) - XCTAssertEqual(store.state.todos.counter, 0) - store.register(reducer: Reducer( + let reducer = Reducer( + id: "Todos Reducer", ReduceOn(incrementActionTemplate) { todosState, action in todosState.counter += action.payload - } - ), for: \.todos) + }) + XCTAssertEqual(store.state.todos.counter, 0) + store.register(reducer: reducer, for: \.todos) // When store.dispatch(action: incrementActionTemplate.createAction(payload: 42)) + // Then XCTAssertEqual(store.state.todos.counter, 42) + + // Given + store.unregister(reducer: reducer) + // When + store.dispatch(action: incrementActionTemplate.createAction(payload: 42)) + // Then + XCTAssertEqual(store.state.todos.counter, 42) + + // Given + store.register(reducer: reducer, for: \.todos) + // When + store.dispatch(action: incrementActionTemplate.createAction(payload: 42)) + // Then + XCTAssertEqual(store.state.todos.counter, 84) } /// Does the `Effect`s get triggered? func testRegisteringEffectsType() { // Given - let interceptor = TestInterceptor() + var interceptor = TestInterceptor() store.register(effects: TestEffects()) store.register(interceptor: interceptor) let firstAction = TestAction() @@ -65,13 +81,41 @@ class StoreTests: XCTestCase { store.dispatch(action: firstAction) // Then wait(for: [environment.expectation], timeout: 1) - let dispatchedActions = interceptor.stateChanges.map(\.action) + var dispatchedActions = interceptor.stateChanges.map(\.action) XCTAssertEqual(dispatchedActions.count, 4) XCTAssertEqual(dispatchedActions[0] as! TestAction, firstAction) XCTAssertEqual(dispatchedActions[1] as! AnonymousAction, environment.responseAction) XCTAssertEqual(dispatchedActions[2] as! AnonymousAction, environment.generateAction) XCTAssertEqual(dispatchedActions[3] as! AnonymousAction, environment.unrelatedAction) XCTAssertEqual(environment.lastAction, environment.generateAction) + + // Given + store.unregisterEffects(ofType: TestEffects.self) + store.unregisterInterceptors(ofType: TestInterceptor.self) + interceptor = TestInterceptor() + store.register(interceptor: interceptor) + // When + store.dispatch(action: firstAction) + // Then + XCTAssertThrowsError(try interceptor.waitForActions(expectedNumberOfActions: 3)) + dispatchedActions = interceptor.stateChanges.map(\.action) + XCTAssertEqual(dispatchedActions.count, 1) + XCTAssertEqual(dispatchedActions[0] as! TestAction, firstAction) + + // Given + environment.resetExpectation() + store.register(effects: TestEffects()) + // When + store.dispatch(action: firstAction) + // Then + wait(for: [environment.expectation], timeout: 1) + dispatchedActions = interceptor.stateChanges.map(\.action) + XCTAssertEqual(dispatchedActions.count, 5) + XCTAssertEqual(dispatchedActions[1] as! TestAction, firstAction) + XCTAssertEqual(dispatchedActions[2] as! AnonymousAction, environment.responseAction) + XCTAssertEqual(dispatchedActions[3] as! AnonymousAction, environment.generateAction) + XCTAssertEqual(dispatchedActions[4] as! AnonymousAction, environment.unrelatedAction) + XCTAssertEqual(environment.lastAction, environment.generateAction) } /// Does the `Effect`s get triggered? @@ -128,6 +172,20 @@ class StoreTests: XCTestCase { XCTAssertEqual(interceptor.stateChanges[0].action as! TestAction, action) XCTAssertEqual(interceptor.stateChanges[0].oldState, oldState) XCTAssertEqual(interceptor.stateChanges[0].newState, store.state) + + // Given + store.unregisterInterceptors(ofType: TestInterceptor.self) + // When + store.dispatch(action: action) + // Then + XCTAssertEqual(interceptor.stateChanges.count, 1) + + // Given + store.register(interceptor: interceptor) + // When + store.dispatch(action: action) + // Then + XCTAssertEqual(interceptor.stateChanges.count, 2) } /// Does a change in `State` publish new value for `Selector`? @@ -209,10 +267,6 @@ private struct TodosState: Encodable, Equatable { } private class TestEnvironment: Equatable { - static func == (lhs: TestEnvironment, rhs: TestEnvironment) -> Bool { - lhs.id == rhs.id - } - let id = UUID() let responseActionIdentifier = "ResponseAction" var responseActionTemplate: ActionTemplate { ActionTemplate(id: responseActionIdentifier) } @@ -223,11 +277,20 @@ private class TestEnvironment: Equatable { var unrelatedAction: AnonymousAction { unrelatedActionTemplate.createAction() } var lastAction: AnonymousAction? var mainThreadCheck = { XCTAssertEqual(Thread.current, Thread.main) } - let expectation: XCTestExpectation = { - let expectation = XCTestExpectation(description: String(describing: TestEffects.self)) + var expectation: XCTestExpectation! + + init() { + resetExpectation() + } + + func resetExpectation() { + expectation = XCTestExpectation(description: String(describing: TestEffects.self)) expectation.expectedFulfillmentCount = 3 - return expectation - }() + } + + static func == (lhs: TestEnvironment, rhs: TestEnvironment) -> Bool { + lhs.id == rhs.id + } } private enum TestType: String, Encodable {