diff --git a/Sources/Puredux/SideEffects/Effect.swift b/Sources/Puredux/SideEffects/Effect.swift new file mode 100644 index 0000000..a9f1bc4 --- /dev/null +++ b/Sources/Puredux/SideEffects/Effect.swift @@ -0,0 +1,170 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 13/04/2024. +// + +import Foundation + +struct Effect { + typealias Operation = () -> Void + + private let perform: Operation? + + var operation: Operation { + perform ?? { } + } + + var canBeExecuted: Bool { + perform != nil + } + + init(_ operation: @escaping () -> Void) { + perform = operation + } + + private init(operation: Operation?) { + perform = operation + } + + + @available(iOS 13.0, *) + init(operation: @escaping () async -> Void) { + perform = { + Task { + await operation() + } + } + } + + static let skip: Effect = Effect(operation: nil) +} + +extension Effect { + struct State: Codable, Equatable, Hashable { + typealias ID = UUID + + private(set) var id = ID() + private var state = InternalState() + + var status: Status { + state.status + } + + var isInProgress: Bool { + status.isInProgress + } + + var isSuccess: Bool { + status.isSuccess + } + + var isCancelled: Bool { + status.isCancelled + } + + var isIdle: Bool { + status.isIdle + } + + var isFailed: Bool { + status.isFailed + } + + var error: Error? { + state.error + } + + var currentAttempt: Int? { + state.currentAttempt?.attempt + } + + var delay: TimeInterval? { + state.currentAttempt?.delay + } + } +} + +extension Effect.State { + static func running(maxAttempts: Int = 1, + delay: TimeInterval = .zero) -> Effect.State { + + var effect = Effect.State() + effect.run(maxAttempts: maxAttempts, delay: delay) + return effect + } + + static func idle() -> Effect.State { + Effect.State() + } +} + +extension Effect.State { + + enum Status: Int { + case none + case inProgress + case success + case failure + case cancelled + } +} + +extension Effect.State.Status { + var isInProgress: Bool { + self == .inProgress + } + + var isSuccess: Bool { + self == .success + } + + var isCancelled: Bool { + self == .cancelled + } + + var isIdle: Bool { + self == .none + } + + var isFailed: Bool { + self == .failure + } +} + +extension Effect.State { + + mutating func run(maxAttempts: Int = 1, + delay: TimeInterval = .zero) { + + state.run(maxAttempts: maxAttempts, delay: delay) + } + + mutating func restart(maxAttempts: Int = 1, + delay: TimeInterval = .zero) { + + state.restart(maxAttempts: maxAttempts, delay: delay) + } + + mutating func retryOrFailWith(_ error: Error?) { + state.retryOrFailWith(error) + } + + mutating func cancel() { + state.cancel() + } + + mutating func succeed() { + state.succeed() + } + + mutating func reset() { + state.reset() + } + + mutating func fail(_ error: Error?) { + state.fail(error) + } +} + diff --git a/Sources/Puredux/SideEffects/EffectOperator.swift b/Sources/Puredux/SideEffects/EffectOperator.swift new file mode 100644 index 0000000..148c4c2 --- /dev/null +++ b/Sources/Puredux/SideEffects/EffectOperator.swift @@ -0,0 +1,58 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 13/04/2024. +// + +import Dispatch +import Foundation + +final class EffectOperator { + private(set) var executing: [Effect.State: Weak] = [:] + private(set) var isSynced = true + + func run(_ inProgress: Bool, + on queue: DispatchQueue, + create: (Effect.State) -> Effect) { + + let effect: Effect.State = inProgress ? .running() : .idle() + run(effect, on: queue, create: create) + } + + func run(_ effect: Effect.State, + on queue: DispatchQueue, + create: (Effect.State) -> Effect) { + + run([effect], on: queue, create: create) + } + + func run(_ effects: Effects, + on queue: DispatchQueue, + create: (Effect.State) -> Effect) + + where Effects: Collection & Hashable, Effects.Element == Effect.State { + + let effectsInProgress = effects.filter { $0.isInProgress } + let expectedToBeExecuting = Set(effectsInProgress) + + executing.keys + .filter { !expectedToBeExecuting.contains($0) } + .forEach { + executing[$0]?.object?.cancel() + executing[$0] = nil + } + + effectsInProgress + .filter { !executing.keys.contains($0) } + .map { state in (state, create(state)) } + .filter { _, effect in effect.canBeExecuted } + .forEach { state, effect in + let workItem = DispatchWorkItem(block: effect.operation) + executing[state] = Weak(workItem) + queue.asyncAfter(delay: state.delay, execute: workItem) + } + + isSynced = expectedToBeExecuting.count == executing.count + } +} diff --git a/Sources/Puredux/SideEffects/EffectState.swift b/Sources/Puredux/SideEffects/EffectState.swift new file mode 100644 index 0000000..6e83e24 --- /dev/null +++ b/Sources/Puredux/SideEffects/EffectState.swift @@ -0,0 +1,185 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 13/04/2024. +// + +import Foundation + +extension Effect.State { + enum InternalState: Codable, Equatable, Hashable { + case none + case inProgress(Attempt) + case success + case failure(Failure) + case cancelled + + init() { + self = .none + } + } +} + +extension Effect.State.InternalState { + var status: Effect.State.Status { + switch self { + case .none: + return .none + case .inProgress: + return .inProgress + case .success: + return .success + case .failure(_): + return .failure + case .cancelled: + return .cancelled + } + } + + var error: Error? { + guard case .failure(let effectFailure) = self else { + return nil + } + + return effectFailure + } + + var currentAttempt: Attempt? { + guard case let .inProgress(attempt) = self else { + return nil + } + + return attempt + } + + var isInProgress: Bool { + currentAttempt != nil + } +} + +extension Effect.State.InternalState { + struct Failure: Codable, Equatable, Hashable, LocalizedError { + let underlyingErrorDescription: String + private(set) var underlyingError: Error? + + private enum CodingKeys: String, CodingKey { + case underlyingErrorDescription + } + + var errorDescription: String? { + underlyingErrorDescription + } + + init(_ error: Error) { + self.underlyingErrorDescription = error.localizedDescription + self.underlyingError = error + } + + static func == (lhs: Failure, rhs: Failure) -> Bool { + lhs.underlyingErrorDescription == rhs.underlyingErrorDescription + } + + func hash(into hasher: inout Hasher) { + hasher.combine(underlyingErrorDescription) + } + } + + enum Errors: LocalizedError { + case unknownEffectExecutionError + + var errorDescription: String? { + "Unknown effect execution error" + } + } +} + +extension Effect.State.InternalState { + mutating func run(maxAttempts: Int, + delay: TimeInterval) { + + guard !isInProgress else { + return + } + + self = .inProgress( + Attempt( + maxAttempts: maxAttempts, + delay: delay + ) + ) + } + + mutating func restart(maxAttempts: Int, + delay: TimeInterval) { + + self = .inProgress( + Attempt( + maxAttempts: maxAttempts, + delay: delay + ) + ) + } + + mutating func retryOrFailWith(_ error: Error?) { + self = nextAttemptOrFailWith(error ?? Errors.unknownEffectExecutionError) + } + + mutating func fail(_ error: Error?) { + self = .failure(Failure(error ?? Errors.unknownEffectExecutionError)) + } + + mutating func cancel() { + self = .cancelled + } + + mutating func succeed() { + self = .success + } + + mutating func reset() { + self = .none + } +} + +extension Effect.State.InternalState { + + func nextAttemptOrFailWith(_ error: Error) -> Effect.State.InternalState { + guard let job = currentAttempt, + let next = job.nextAttempt() + else { + return .failure(Failure(error)) + } + + return .inProgress(next) + } +} + + +extension Effect.State.InternalState { + + struct Attempt: Codable, Equatable, Hashable { + typealias ID = UUID + + private(set) var id = ID() + private(set) var attempt: Int = 0 + private(set) var maxAttempts: Int + private(set) var delay: TimeInterval + + var hasMoreAttempts: Bool { + attempt < (maxAttempts - 1) + } + + func nextAttempt() -> Attempt? { + guard hasMoreAttempts else { + return nil + } + + return Attempt( + attempt: attempt + 1, + maxAttempts: maxAttempts, + delay: pow(2.0, Double(attempt + 1)) + ) + } + } +} diff --git a/Sources/Puredux/SideEffects/SideEffects.swift b/Sources/Puredux/SideEffects/SideEffects.swift new file mode 100644 index 0000000..368ca88 --- /dev/null +++ b/Sources/Puredux/SideEffects/SideEffects.swift @@ -0,0 +1,174 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 13/04/2024. +// + +import Dispatch + +extension Store { + func effect(on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) where State == Effect.State { + + effect(\.self, on: queue, create: create) + } + + func forEachEffect(on queue: DispatchQueue = .main, + create: @escaping (State, Effect.State) -> Effect) + + where State: Collection & Hashable, State.Element == Effect.State { + + forEachEffect(\.self, on: queue, create: create) + } + + func effect(on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) where State: Equatable { + + effect(\.self, on: queue, create: create) + } + + func effect(on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) where State == Bool { + + effect(\.self, on: queue, create: create) + } +} + +extension StateStore { + func effect(on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) where State == Effect.State { + + strongStore().effect(on: queue, create: create) + } + + func forEachEffect(on queue: DispatchQueue = .main, + create: @escaping (State, Effect.State) -> Effect) + + where State: Collection & Hashable, State.Element == Effect.State { + + strongStore().forEachEffect(on: queue, create: create) + } + + func effect(on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) where State: Equatable { + + strongStore().effect(on: queue, create: create) + } + + func effect(on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) where State == Bool { + + strongStore().effect(on: queue, create: create) + } +} + +extension StateStore { + func forEachEffect(_ keyPath: KeyPath, + on queue: DispatchQueue = .main, + create: @escaping (State, Effect.State) -> Effect) + + where Effects: Collection & Hashable, Effects.Element == Effect.State { + + strongStore().forEachEffect(keyPath, on: queue, create: create) + } + + func effect(_ keyPath: KeyPath, + on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) { + + strongStore().effect(keyPath, on: queue, create: create) + } + + func effect(_ keyPath: KeyPath, + on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) where T: Equatable { + + strongStore().effect(keyPath, on: queue, create: create) + } + + func effect(_ keyPath: KeyPath, + on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) { + + strongStore().effect(keyPath, on: queue, create: create) + } +} + +extension Store { + func forEachEffect(_ keyPath: KeyPath, + on queue: DispatchQueue = .main, + create: @escaping (State, Effect.State) -> Effect) + where Effects: Collection & Hashable, Effects.Element == Effect.State { + + let effectOperator = EffectOperator() + + subscribe(observer: Observer( + removeStateDuplicates: .keyPath(keyPath)) { [effectOperator] state, prevState, complete in + + let allEffects = state[keyPath: keyPath] + effectOperator.run(allEffects, on: queue) { effectState in + create(state, effectState) + } + complete(.active) + return effectOperator.isSynced ? state : prevState + } + ) + } + + func effect(_ keyPath: KeyPath, + on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) { + + let effectOperator = EffectOperator() + + subscribe(observer: Observer( + removeStateDuplicates: .keyPath(keyPath)) { [effectOperator] state, prevState, complete in + + let effect = state[keyPath: keyPath] + effectOperator.run(effect, on: queue) { _ in + create(state) + } + complete(.active) + return effectOperator.isSynced ? state : prevState + } + ) + } + + func effect(_ keyPath: KeyPath, + on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) where T: Equatable { + + let effectOperator = EffectOperator() + + subscribe(observer: Observer( + removeStateDuplicates: .keyPath(keyPath)) { [effectOperator] state, prevState, complete in + let effect: Effect.State = prevState == nil ? .idle() : .running() + effectOperator.run(effect, on: queue) { _ in + create(state) + } + complete(.active) + return effectOperator.isSynced ? state : prevState + } + ) + } + + func effect(_ keyPath: KeyPath, + on queue: DispatchQueue = .main, + create: @escaping (State) -> Effect) { + + let effectOperator = EffectOperator() + + subscribe(observer: Observer( + removeStateDuplicates: .keyPath(keyPath)) { [effectOperator] state, prevState, complete in + + let isRunning = state[keyPath: keyPath] + effectOperator.run(isRunning, on: queue) { _ in + create(state) + } + complete(.active) + return effectOperator.isSynced ? state : prevState + } + ) + } +} diff --git a/Sources/Puredux/Store/Core/StoreNode.swift b/Sources/Puredux/Store/Core/StoreNode.swift index 6b18475..0a8931b 100644 --- a/Sources/Puredux/Store/Core/StoreNode.swift +++ b/Sources/Puredux/Store/Core/StoreNode.swift @@ -53,7 +53,7 @@ final class StoreNode where ParentStore: } private lazy var parentObserver: Observer = { - Observer(removeStateDuplicates: .neverEqual) { [weak self] parentState, complete in + Observer(self, removeStateDuplicates: .neverEqual) { [weak self] parentState, complete in guard let self else { complete(.dead) return @@ -64,7 +64,7 @@ final class StoreNode where ParentStore: }() private lazy var localObserver: Observer = { - Observer(removeStateDuplicates: .neverEqual) { [weak self] _, complete in + Observer(self, removeStateDuplicates: .neverEqual) { [weak self] _, complete in guard let self else { complete(.dead) return @@ -188,3 +188,4 @@ private extension StoreNode { } } } + diff --git a/Sources/Puredux/Store/Observer.swift b/Sources/Puredux/Store/Observer.swift index ad17256..92b91ae 100644 --- a/Sources/Puredux/Store/Observer.swift +++ b/Sources/Puredux/Store/Observer.swift @@ -18,13 +18,13 @@ public extension Observer { /// - Parameter status: observer status to handle /// typealias StatusHandler = (_ status: ObserverStatus) -> Void - + /// Observer's main closure that handle State changes and calls complete handler /// - Parameter state: newly observed State /// - Parameter complete: complete handler that Observer calls when the work is done /// typealias StateHandler = (_ state: State, _ complete: @escaping StatusHandler) -> Void - + /// Observer's main closure that handle State changes and calls complete handler /// - Parameter state: newly observed State /// - Parameter prev: previous State @@ -37,23 +37,25 @@ public extension Observer { /// public struct Observer: Hashable { let id: UUID - - private let stateHandler: StateObserver - private let removeStateDuplicates: Equating? - private let prevState: Referenced = Referenced(value: nil) - + + private let stateHandler: StatesObserver + private let keepsCurrentState: Bool + private let prevState: Referenced = Referenced(nil) + var state: State? { prevState.value } - + func send(_ state: State, complete: @escaping StatusHandler) { - guard removesStateDuplicates else { - stateHandler(state, nil, complete) + guard keepsCurrentState else { + let _ = stateHandler(state, nil, complete) + prevState.value = nil return } + let prev = prevState.value - prevState.value = state - stateHandler(state, prev, complete) + prevState.value = stateHandler(state, prev, complete) + } } @@ -88,7 +90,7 @@ public extension Observer { static func == (lhs: Observer, rhs: Observer) -> Bool { lhs.id == rhs.id } - + func hash(into hasher: inout Hasher) { hasher.combine(id) } @@ -97,64 +99,77 @@ public extension Observer { extension Observer { init(id: UUID = UUID(), observe: @escaping StateHandler) { self.id = id - self.removeStateDuplicates = nil - self.stateHandler = { state, _, complete in observe(state, complete) } + self.keepsCurrentState = false + self.stateHandler = { state, _, complete in + observe(state, complete) + return nil + } } - + + init(id: UUID = UUID(), - removeStateDuplicates equating: Equating, - observe: @escaping StateHandler) { - self.id = id - self.removeStateDuplicates = equating - self.stateHandler = { state, _, complete in observe(state, complete) } - } - - init(id: UUID = UUID(), observe: @escaping StateObserver) { + observe: @escaping StateObserver) { self.id = id - self.removeStateDuplicates = nil - self.stateHandler = observe + self.keepsCurrentState = false + self.stateHandler = { state, prevState, complete in + observe(state, prevState, complete) + return state + } } - + init(_ observer: AnyObject, id: UUID = UUID(), removeStateDuplicates equating: Equating? = nil, observe: @escaping StateHandler) { - + self.id = id - self.removeStateDuplicates = equating + self.keepsCurrentState = equating != nil self.stateHandler = { [weak observer] state, prevState, complete in guard observer != nil else { complete(.dead) - return + return state } - + guard let equating else { observe(state, complete) - return + return state } - + guard !equating.isEqual(state, to: prevState) else { complete(.active) - return + return state } - + observe(state, complete) + return state } } -} - -private extension Observer { - var removesStateDuplicates: Bool { - removeStateDuplicates != nil + + init(id: UUID = UUID(), + keepsCurrentState: Bool, + observe: @escaping StatesObserver) { + self.id = id + self.keepsCurrentState = keepsCurrentState + self.stateHandler = observe } -} - -private extension Observer { - final class Referenced { - var value: T - - init(value: T) { - self.value = value + + init(id: UUID = UUID(), + removeStateDuplicates equating: Equating, + observe: @escaping StatesObserver) { + + self.init(id: id, keepsCurrentState: true) { state, prevState, complete in + + guard !equating.isEqual(state, to: prevState) else { + complete(.active) + return state + } + + return observe(state, prevState, complete) } } } + +extension Observer { + typealias StatesObserver = (_ state: State, _ prev: State?, _ complete: @escaping StatusHandler) -> State? + +} diff --git a/Sources/Puredux/Utils/DispatchQueue+Delay.swift b/Sources/Puredux/Utils/DispatchQueue+Delay.swift new file mode 100644 index 0000000..8fab269 --- /dev/null +++ b/Sources/Puredux/Utils/DispatchQueue+Delay.swift @@ -0,0 +1,19 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 18/04/2024. +// + +import Foundation + +extension DispatchQueue { + func asyncAfter(delay: TimeInterval?, execute workItem: DispatchWorkItem) { + guard let delay, delay > .zero else { + async(execute: workItem) + return + } + + asyncAfter(deadline: .now() + delay, execute: workItem) + } +} diff --git a/Sources/Puredux/Utils/Equating.swift b/Sources/Puredux/Utils/Equating.swift index b596236..d8b6826 100644 --- a/Sources/Puredux/Utils/Equating.swift +++ b/Sources/Puredux/Utils/Equating.swift @@ -29,6 +29,10 @@ public extension Equating { value($0) == value($1) } } + + static func keyPath(_ keyPath: KeyPath) -> Equating { + .equal(value: { $0[keyPath: keyPath]} ) + } } public extension Equating { diff --git a/Sources/Puredux/Utils/Referenced.swift b/Sources/Puredux/Utils/Referenced.swift new file mode 100644 index 0000000..c00dc1b --- /dev/null +++ b/Sources/Puredux/Utils/Referenced.swift @@ -0,0 +1,16 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 18/04/2024. +// + +import Foundation + +final class Referenced { + var value: T + + init(_ value: T) { + self.value = value + } +} diff --git a/Sources/Puredux/Utils/WeakReferenced.swift b/Sources/Puredux/Utils/WeakReferenced.swift new file mode 100644 index 0000000..455775d --- /dev/null +++ b/Sources/Puredux/Utils/WeakReferenced.swift @@ -0,0 +1,16 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 18/04/2024. +// + +import Foundation + +final class Weak { + weak var object: T? + + init(_ object: T?) { + self.object = object + } +} diff --git a/Tests/PureduxTests/SideEffectsTests/EffectStateTests.swift b/Tests/PureduxTests/SideEffectsTests/EffectStateTests.swift new file mode 100644 index 0000000..a8647e2 --- /dev/null +++ b/Tests/PureduxTests/SideEffectsTests/EffectStateTests.swift @@ -0,0 +1,123 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 17/04/2024. +// + +import Foundation + +import XCTest +@testable import Puredux + +final class EfectsStateTests: XCTestCase { + struct DummyError: Equatable, LocalizedError { + let message: String + + var errorDescription: String? { + message + } + } + + func test_WhenMutated_ThenStateChanges() { + var effect = Effect.State() + XCTAssertTrue(effect.isIdle) + + effect.run() + XCTAssertTrue(effect.isInProgress) + + effect.cancel() + XCTAssertTrue(effect.isCancelled) + + effect.restart(maxAttempts: 2) + XCTAssertTrue(effect.isInProgress) + + effect.retryOrFailWith(nil) + XCTAssertTrue(effect.isInProgress) + + effect.fail(nil) + XCTAssertTrue(effect.isFailed) + + effect.run() + XCTAssertTrue(effect.isInProgress) + + effect.succeed() + XCTAssertTrue(effect.isSuccess) + + effect.reset() + XCTAssertTrue(effect.isIdle) + } + + func test_WhenRunTwice_ThenNotMutated() { + var effect = Effect.State() + effect.run() + + let effectCopy = effect + effect.run() + + XCTAssertEqual(effect, effectCopy) + } + + func test_WhenRestared_ThenMutated() { + var effect = Effect.State() + effect.run() + let effectCopy = effect + effect.restart() + + XCTAssertNotEqual(effect, effectCopy) + } + + func test_WhenFailed_ThenErrorLocalizedDescription() { + var effect = Effect.State() + let error = DummyError(message: "something happened") + effect.fail(error) + + XCTAssertEqual(effect.error?.localizedDescription, "something happened") + } + + func test_WhenFailedWithUnknownError_ThenErrorLocalizedDescription() { + var effect = Effect.State() + effect.fail(nil) + + XCTAssertEqual(effect.error?.localizedDescription, "Unknown effect execution error") + } + + func test_WhenHasNewAttempts_ThenRestartThenFail() { + var effect = Effect.State() + effect.run(maxAttempts: 5) + + XCTAssertEqual(effect.currentAttempt, 0) + XCTAssertEqual(effect.delay, .zero) + XCTAssertTrue(effect.isInProgress) + + effect.retryOrFailWith(nil) + XCTAssertNil(effect.error) + XCTAssertEqual(effect.currentAttempt, 1) + XCTAssertEqual(effect.delay, 2) + XCTAssertTrue(effect.isInProgress) + + effect.retryOrFailWith(nil) + XCTAssertNil(effect.error) + XCTAssertEqual(effect.currentAttempt, 2) + XCTAssertEqual(effect.delay, 4) + XCTAssertTrue(effect.isInProgress) + + effect.retryOrFailWith(nil) + XCTAssertNil(effect.error) + XCTAssertEqual(effect.currentAttempt, 3) + XCTAssertEqual(effect.delay, 8) + XCTAssertTrue(effect.isInProgress) + + effect.retryOrFailWith(nil) + XCTAssertNil(effect.error) + XCTAssertEqual(effect.currentAttempt, 4) + XCTAssertEqual(effect.delay, 16) + XCTAssertTrue(effect.isInProgress) + + effect.retryOrFailWith(nil) + XCTAssertNotNil(effect.error) + XCTAssertEqual(effect.currentAttempt, nil) + XCTAssertTrue(effect.isFailed) + + } +} diff --git a/Tests/PureduxTests/SideEffectsTests/SideEfectsBoolTests.swift b/Tests/PureduxTests/SideEffectsTests/SideEfectsBoolTests.swift new file mode 100644 index 0000000..d824e12 --- /dev/null +++ b/Tests/PureduxTests/SideEffectsTests/SideEfectsBoolTests.swift @@ -0,0 +1,137 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 16/04/2024. +// + +import Foundation + +import XCTest +@testable import Puredux + +final class SideEfectsBoolTests: XCTestCase { + let timeout: TimeInterval = 3.0 + + func test_WhenStateIsToggledToTrue_EffectExecuted() { + let store = StateStore(initialState: false, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect executed") + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(false) + store.dispatch(true) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenBoolIsSetToTrueMultipleTime_EffectExecutedOnce() { + let store = StateStore(initialState: false, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect executed") + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(true) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenBoolIsToggledTrueMultiple_EffectExecutedOnEveryToggle() { + let store = StateStore(initialState: false, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.expectedFulfillmentCount = 3 + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(false) + store.dispatch(true) + store.dispatch(false) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenEffectIsSkipped_ThenEffectCreationIsCalledAgain() { + let store = StateStore(initialState: false, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.expectedFulfillmentCount = 3 + store.effect(on: .main) { _ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(true) + store.dispatch(true) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsNotChangedAndFalse_ThenEffectIsNotCreated() { + let store = StateStore(initialState: false, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.isInverted = true + + store.effect(on: .main) { _ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsNotChangedAndFalse_ThenEffectIsNotExecuted() { + let store = StateStore(initialState: false, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.isInverted = true + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } +} diff --git a/Tests/PureduxTests/SideEffectsTests/SideEfectsCollectionTests.swift b/Tests/PureduxTests/SideEffectsTests/SideEfectsCollectionTests.swift new file mode 100644 index 0000000..290b5fa --- /dev/null +++ b/Tests/PureduxTests/SideEffectsTests/SideEfectsCollectionTests.swift @@ -0,0 +1,315 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 17/04/2024. +// + +import Foundation + +import Foundation +import XCTest +@testable import Puredux + +final class SideEffectsCollectionTests: XCTestCase { + let timeout: TimeInterval = 3.0 + + func test_WhenStateIsRun_EffectExecuted() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.run() : effect.cancel() + return effect + } + } + ) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.expectedFulfillmentCount = 2 + store.forEachEffect(on: .main) { _,_ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(false) + store.dispatch(true) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsRunMultipleTimes_EachEffectExecutedOnce() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.run() : effect.cancel() + return effect + } + } + ) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.expectedFulfillmentCount = 2 + store.forEachEffect(on: .main) { _,_ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(true) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenRestartedMultiple_ThenEachEffectExecutedForEveryRestart() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.restart() : effect.cancel() + return effect + } + } + ) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.expectedFulfillmentCount = 6 + store.forEachEffect(on: .main) { _,_ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(false) + store.dispatch(true) + store.dispatch(false) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenEffectIsSkipped_ThenEachEffectCreationIsCalledAgain() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.restart() : effect.cancel() + return effect + } + } + ) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.expectedFulfillmentCount = 6 + store.forEachEffect(on: .main) { _,_ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(true) + store.dispatch(true) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsInitial_ThenNoEffectIsCreated() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.restart() : effect.reset() + return effect + } + } + ) + + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.isInverted = true + + store.forEachEffect(on: .main) { _,_ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsSuccess_ThenNoEffectIsCreated() { + + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.restart() : effect.succeed() + return effect + } + } + ) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.isInverted = true + + store.forEachEffect(on: .main) { _,_ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsFailed_ThenNoEffectIsCreated() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.restart() : effect.fail(nil) + return effect + } + } + ) + + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.isInverted = true + + store.forEachEffect(on: .main) { _,_ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateRetryOrFailAndHasAttempts_ThenEachEffectIsReExecuted() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.run(maxAttempts: 2) : effect.retryOrFailWith(nil) + return effect + } + } + ) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.expectedFulfillmentCount = 4 + + store.forEachEffect(on: .main) { _,_ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateRetryOrFailAndHasNoAttempts_ThenEachEffectIsExecutedOnce() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.run(maxAttempts: 1) : effect.retryOrFailWith(nil) + return effect + } + } + ) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.expectedFulfillmentCount = 2 + + store.forEachEffect(on: .main) { _,_ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsNotRunning_ThenNoEffectIsExecuted() { + let store = StateStore<[Effect.State], Bool>( + initialState: [Effect.State(), Effect.State()], + reducer: { state, action in + state = state.map { + var effect = $0 + action ? effect.run() : effect.cancel() + return effect + } + } + ) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.isInverted = true + + store.forEachEffect(on: .main) { _,_ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } +} diff --git a/Tests/PureduxTests/SideEffectsTests/SideEfectsEquatableTests.swift b/Tests/PureduxTests/SideEffectsTests/SideEfectsEquatableTests.swift new file mode 100644 index 0000000..be72005 --- /dev/null +++ b/Tests/PureduxTests/SideEffectsTests/SideEfectsEquatableTests.swift @@ -0,0 +1,93 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 16/04/2024. +// + +import Foundation + +import XCTest +@testable import Puredux + +final class SideEfectsEquatableTests: XCTestCase { + let timeout: TimeInterval = 3.0 + + func test_WhenStateChanged_EffectExecuted() { + let store = StateStore(initialState: 0, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect executed") + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(1) + store.dispatch(1) + store.dispatch(1) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateChangedAndEffectSkipped_EffectCreationIsCalledAgain() { + let store = StateStore(initialState: 0, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.expectedFulfillmentCount = 3 + store.effect(on: .main) { _ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(1) + store.dispatch(1) + store.dispatch(1) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateNotChanged_EffectNotExecuted() { + let store = StateStore(initialState: 0, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.isInverted = true + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(0) + store.dispatch(0) + store.dispatch(0) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateNotChanged_EffectCreationIsNotCalled() { + let store = StateStore(initialState: 0, reducer: { state, action in state = action }) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.isInverted = true + store.effect(on: .main) { _ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(0) + store.dispatch(0) + store.dispatch(0) + + waitForExpectations(timeout: timeout) { _ in + + } + } +} diff --git a/Tests/PureduxTests/SideEffectsTests/SideEfectsStateTests.swift b/Tests/PureduxTests/SideEffectsTests/SideEfectsStateTests.swift new file mode 100644 index 0000000..49ea4f6 --- /dev/null +++ b/Tests/PureduxTests/SideEffectsTests/SideEfectsStateTests.swift @@ -0,0 +1,261 @@ +// +// File.swift +// +// +// Created by Sergey Kazakov on 17/04/2024. +// + +import Foundation +import XCTest +@testable import Puredux + +final class SideEfectsStateTests: XCTestCase { + let timeout: TimeInterval = 3.0 + + func test_WhenStateIsRun_EffectExecuted() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.run() : state.cancel() }) + + let asyncExpectation = expectation(description: "Effect executed") + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(false) + store.dispatch(true) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsRunMultipleTimes_EffectExecutedOnce() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.run() : state.cancel() }) + let asyncExpectation = expectation(description: "Effect executed") + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(true) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenRestartedMultiple_EffectExecutedForEveryRestart() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.restart() : state.cancel() }) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.expectedFulfillmentCount = 3 + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(false) + store.dispatch(true) + store.dispatch(false) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenEffectIsSkipped_ThenEffectCreationIsCalledAgain() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.restart() : state.cancel() }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.expectedFulfillmentCount = 3 + store.effect(on: .main) { _ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(true) + store.dispatch(true) + store.dispatch(true) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsInitial_ThenEffectIsNotCreated() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.run() : state.cancel() }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.isInverted = true + + store.effect(on: .main) { _ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsIdle_ThenEffectIsNotCreated() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.run() : state.reset() }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.isInverted = true + + store.effect(on: .main) { _ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsSuccess_ThenEffectIsNotCreated() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.run() : state.succeed() }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.isInverted = true + + store.effect(on: .main) { _ in + asyncExpectation.fulfill() + return .skip + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsFailed_ThenEffectIsNotCreated() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.run() : state.fail(nil) }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.isInverted = true + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateRetryOrFailAndHasAttempts_ThenEffectIsReExecuted() { + let store = StateStore( + initialState: Effect.State(), + reducer: { state, action in + action ? + state.run(maxAttempts: 2) + : state.retryOrFailWith(nil) + }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.expectedFulfillmentCount = 2 + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateRetryOrFailAndHasNoAttempts_ThenEffectIsExecutedOnce() { + let store = StateStore( + initialState: Effect.State(), + reducer: { state, action in + action ? + state.run(maxAttempts: 1) + : state.retryOrFailWith(nil) + }) + + let asyncExpectation = expectation(description: "Effect creation executed") + asyncExpectation.expectedFulfillmentCount = 1 + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(true) + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } + + func test_WhenStateIsNotRunning_ThenEffectIsNotExecuted() { + let store = StateStore(initialState: Effect.State(), + reducer: { state, action in action ? state.run() : state.cancel() }) + + let asyncExpectation = expectation(description: "Effect executed") + asyncExpectation.isInverted = true + + store.effect(on: .main) { _ in + Effect { + asyncExpectation.fulfill() + } + } + + store.dispatch(false) + store.dispatch(false) + store.dispatch(false) + + waitForExpectations(timeout: timeout) { _ in + + } + } +}