diff --git a/CHANGELOG.md b/CHANGELOG.md index 588c56c..6d8cb70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +**version 0.18.0**: + +* breaking: Executers are no longer associated to a Reducer but to the whole Spin instead (and can still be overriden in each Feedback) + **version 0.17.0**: * introduce Gear: a mediator pattern between Spins that allows them to communicate together diff --git a/Cartfile b/Cartfile index 882c76b..15690cd 100644 --- a/Cartfile +++ b/Cartfile @@ -1,2 +1,2 @@ github "ReactiveX/RxSwift" ~> 5.1.1 -github "ReactiveCocoa/ReactiveSwift" ~> 6.1 +github "ReactiveCocoa/ReactiveSwift" ~> 6.3.0 diff --git a/Cartfile.resolved b/Cartfile.resolved index 89d8abd..177d54e 100644 --- a/Cartfile.resolved +++ b/Cartfile.resolved @@ -1,2 +1,2 @@ -github "ReactiveCocoa/ReactiveSwift" "6.2.1" +github "ReactiveCocoa/ReactiveSwift" "6.3.0" github "ReactiveX/RxSwift" "5.1.1" diff --git a/Package.resolved b/Package.resolved index 1a321b3..7f3ee25 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,49 +1,13 @@ { "object": { "pins": [ - { - "package": "CwlCatchException", - "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", - "state": { - "branch": null, - "revision": "7cd2f8cacc4d22f21bc0b2309c3b18acf7957b66", - "version": "1.2.0" - } - }, - { - "package": "CwlPreconditionTesting", - "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state": { - "branch": null, - "revision": "c228db5d2ad1b01ebc84435e823e6cca4e3db98b", - "version": "1.2.0" - } - }, - { - "package": "Nimble", - "repositoryURL": "https://github.com/Quick/Nimble.git", - "state": { - "branch": null, - "revision": "b02b00b30b6353632aa4a5fb6124f8147f7140c0", - "version": "8.0.5" - } - }, - { - "package": "Quick", - "repositoryURL": "https://github.com/Quick/Quick.git", - "state": { - "branch": null, - "revision": "33682c2f6230c60614861dfc61df267e11a1602f", - "version": "2.2.0" - } - }, { "package": "ReactiveSwift", "repositoryURL": "https://github.com/ReactiveCocoa/ReactiveSwift", "state": { "branch": null, - "revision": "e27ccdbf4ec36f154b60b91a0d7e0110c4e882cb", - "version": "6.2.1" + "revision": "3f4351d04115fd8797802d9b2d17b812cd761602", + "version": "6.3.0" } }, { @@ -51,8 +15,8 @@ "repositoryURL": "https://github.com/ReactiveX/RxSwift", "state": { "branch": null, - "revision": "c1bd31b397d87a54467af4161dde9d6b27720c19", - "version": "5.1.0" + "revision": "002d325b0bdee94e7882e1114af5ff4fe1e96afa", + "version": "5.1.1" } } ] diff --git a/Package.swift b/Package.swift index 888a7eb..65c4318 100644 --- a/Package.swift +++ b/Package.swift @@ -27,8 +27,8 @@ let package = Package( targets: ["SpinRxSwift"]), ], dependencies: [ - .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift", from: "6.2.1"), - .package(url: "https://github.com/ReactiveX/RxSwift", from: "5.1.0"), + .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift", from: "6.3.0"), + .package(url: "https://github.com/ReactiveX/RxSwift", from: "5.1.1"), // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), ], diff --git a/README.md b/README.md index 099a5da..9f9fc7c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Spin Logo

-**With the recent introduction of Combine and SwiftUI, we will face some transition periods in our code base. Our applications will use both Combine and a third-party reactive framework, or both UIKit and SwiftUI, which makes it potentially difficult to guarantee a consistent architecture over time.** +**With the introduction of Combine and SwiftUI, we will face some transition periods in our code base. Our applications will use both Combine and a third-party reactive framework, or both UIKit and SwiftUI, which makes it potentially difficult to guarantee a consistent architecture over time.** **Spin is a tool to build feedback loops within a Swift based application allowing you to use a unified syntax whatever the underlying reactive programming framework and whatever Apple UI technology you use (RxSwift, ReactiveSwift, Combine and UIKit, AppKit, SwiftUI).** @@ -251,11 +251,13 @@ Choose wisely the option that fits your needs. Not cancelling previous operation Reactive programming is often associated with asynchronous execution. Even though every reactive framework comes with its own GCD abstraction, it is always about stating which scheduler the side effect should be executed on. -Spin provides a way to specify this scheduler for each feedback you add to a loop while still being as declarative as possible: +By default, a Spin will be executed on a background thread created by the framework. + +However, Spin provides a way to specify a scheduler for the Spin it-self and for each feedback you add to it: ```swift Spinner - .initialState(Levels(left: 10, right: 20)) + .initialState(Levels(left: 10, right: 20), executeOn: MainScheduler.instance) .feedback(Feedback(effect: leftEffect, on: SerialDispatchQueueScheduler(qos: .userInitiated))) .feedback(Feedback(effect: rightEffect, on: SerialDispatchQueueScheduler(qos: .userInitiated))) .reducer(Reducer(levelsReducer)) @@ -263,7 +265,7 @@ Spinner or ```swift -Spin(initialState: Levels(left: 10, right: 20)) { +Spin(initialState: Levels(left: 10, right: 20), executeOn: MainScheduler.instance) { Feedback(effect: leftEffect) .execute(on: SerialDispatchQueueScheduler(qos: .userInitiated)) Feedback(effect: rightEffect) @@ -486,7 +488,7 @@ https://github.com/Spinners/Spin.Swift.git Add the following entry to your Cartfile: ``` -github "Spinners/Spin.Swift" ~> 0.17.0 +github "Spinners/Spin.Swift" ~> 0.18.0 ``` and then: @@ -500,9 +502,9 @@ carthage update Spin.Swift Add the following dependencies to your Podfile: ``` -pod 'SpinReactiveSwift', '~> 0.17.0' -pod 'SpinCombine', '~> 0.17.0' -pod 'SpinRxSwift', '~> 0.17.0' +pod 'SpinReactiveSwift', '~> 0.18.0' +pod 'SpinCombine', '~> 0.18.0' +pod 'SpinRxSwift', '~> 0.18.0' ``` You should then be able to import SpinCommon (base implementation), SpinRxSwift, SpinReactiveSwift or SpinCombine diff --git a/Sources/Combine/AnyCancellable+DisposeBag.swift b/Sources/Combine/AnyCancellable+DisposeBag.swift deleted file mode 100644 index 72bb043..0000000 --- a/Sources/Combine/AnyCancellable+DisposeBag.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// AnyCancellable+DisposeBag.swift -// -// -// Created by Thibault Wittemberg on 2019-12-30. -// - -import Combine - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public extension AnyCancellable { - func disposed(by disposables: inout [AnyCancellable]) { - self.store(in: &disposables) - } -} diff --git a/Sources/Combine/AnyPublisher+streamFromSpin.swift b/Sources/Combine/AnyPublisher+streamFromSpin.swift index 67788f7..48a8b21 100644 --- a/Sources/Combine/AnyPublisher+streamFromSpin.swift +++ b/Sources/Combine/AnyPublisher+streamFromSpin.swift @@ -7,29 +7,36 @@ import Combine import SpinCommon +import Dispatch @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) public extension AnyPublisher where Failure == Never { - static func stream(from spin: Spin) -> AnyPublisher { - return Deferred> { [weak spin] in + static func stream(from spin: ScheduledSpin) -> AnyPublisher + where + Executer: ExecuterDefinition, + Executer.Executer: Scheduler { + return Deferred> { [weak spin] in - guard let spin = spin else { return Empty().eraseToAnyPublisher() } + guard let spin = spin else { return Empty().eraseToAnyPublisher() } - let currentState = CurrentValueSubject(spin.initialState) + let currentState = CurrentValueSubject(spin.initialState) - // merging all the effects into one event stream - let eventStreams = spin.effects.map { $0(currentState.eraseToAnyPublisher()) } - let eventStream = Publishers.MergeMany(eventStreams).eraseToAnyPublisher() + // merging all the effects into one event stream + let stateInputStream = currentState.eraseToAnyPublisher() + let eventStreams = spin.effects.map { $0(stateInputStream) } + let eventStream = Publishers.MergeMany(eventStreams).eraseToAnyPublisher() - return spin - .scheduledReducer(eventStream) - .prepend(spin.initialState) - .handleEvents(receiveOutput: currentState.send) - .eraseToAnyPublisher() - }.eraseToAnyPublisher() + return eventStream + .subscribe(on: spin.executer) + .receive(on: spin.executer) + .scan(spin.initialState, spin.reducer) + .handleEvents(receiveOutput: currentState.send) + .eraseToAnyPublisher() + }.eraseToAnyPublisher() } - static func start(spin: Spin) -> AnyCancellable { + static func start(spin: ScheduledSpin) -> AnyCancellable + where Executer: ExecuterDefinition, Executer.Executer: Scheduler { AnyPublisher.stream(from: spin).consume() } } diff --git a/Sources/Combine/AnyScheduler.swift b/Sources/Combine/AnyScheduler.swift index 5318fca..e7874fc 100644 --- a/Sources/Combine/AnyScheduler.swift +++ b/Sources/Combine/AnyScheduler.swift @@ -6,6 +6,9 @@ // import Combine +import Dispatch +import Foundation +import SpinCommon @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) public extension Scheduler { diff --git a/Sources/Combine/DispatchQueue+Executer.swift b/Sources/Combine/DispatchQueue+Executer.swift new file mode 100644 index 0000000..af0f83a --- /dev/null +++ b/Sources/Combine/DispatchQueue+Executer.swift @@ -0,0 +1,17 @@ +// +// DispatchQueue+Executer.swift +// +// +// Created by Thibault Wittemberg on 2020-08-04. +// + +import Dispatch +import Foundation +import SpinCommon + +extension DispatchQueue: ExecuterDefinition { + public typealias Executer = DispatchQueue + public static func defaultSpinExecuter() -> Executer { + DispatchQueue(label: "io.warpfactor.spin.dispatch-queue.\(UUID())") + } +} diff --git a/Sources/Combine/Feedback.swift b/Sources/Combine/Feedback.swift index 8d44ec3..f0ddf22 100644 --- a/Sources/Combine/Feedback.swift +++ b/Sources/Combine/Feedback.swift @@ -15,6 +15,10 @@ public typealias ScheduledCombineFeedback = SpinCombine.ScheduledFeedback @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) public typealias CombineFeedback = SpinCombine.Feedback +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias Feedback = + ScheduledFeedback + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) public struct ScheduledFeedback: FeedbackDefinition where SchedulerTime: Strideable, SchedulerTime.Stride: SchedulerTimeIntervalConvertible { @@ -81,7 +85,3 @@ where SchedulerTime: Strideable, SchedulerTime.Stride: SchedulerTimeIntervalConv self.init(effect: effect, on: executer) } } - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public typealias Feedback = - ScheduledFeedback diff --git a/Sources/Combine/OperationQueue+Executer.swift b/Sources/Combine/OperationQueue+Executer.swift new file mode 100644 index 0000000..80d1c02 --- /dev/null +++ b/Sources/Combine/OperationQueue+Executer.swift @@ -0,0 +1,19 @@ +// +// OperationQueue+Executer.swift +// +// +// Created by Thibault Wittemberg on 2020-08-04. +// + +import Foundation +import SpinCommon + +extension OperationQueue: ExecuterDefinition { + public typealias Executer = OperationQueue + public static func defaultSpinExecuter() -> Executer { + let queue = OperationQueue() + queue.name = "io.warpfactor.spin.operationqueue.\(UUID())" + queue.maxConcurrentOperationCount = 1 + return queue + } +} diff --git a/Sources/Combine/Reducer.swift b/Sources/Combine/Reducer.swift deleted file mode 100644 index b186e3b..0000000 --- a/Sources/Combine/Reducer.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// Reducer.swift -// -// -// Created by Thibault Wittemberg on 2019-12-31. -// - -import Combine -import Dispatch -import SpinCommon - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public typealias ScheduledCombineReducer = SpinCombine.ScheduledReducer - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public typealias CombineReducer = SpinCombine.Reducer - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public struct ScheduledReducer: ReducerDefinition -where SchedulerTime: Strideable, SchedulerTime.Stride: SchedulerTimeIntervalConvertible { - public typealias StateStream = AnyPublisher - public typealias EventStream = AnyPublisher - public typealias Executer = AnyScheduler - - public let reducer: (StateStream.Value, EventStream.Value) -> StateStream.Value - public let executer: Executer - - public init(_ reducer: @escaping (StateStream.Value, EventStream.Value) -> StateStream.Value, on executer: Executer) { - self.reducer = reducer - self.executer = executer - } - - public func scheduledReducer(with initialState: StateStream.Value) -> (EventStream) -> StateStream { - return { events in - events - .receive(on: self.executer) - .scan(initialState, self.reducer) - .eraseToAnyPublisher() - } - } -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public typealias Reducer - = ScheduledReducer - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public extension ScheduledReducer -where SchedulerTime == DispatchQueue.SchedulerTimeType, SchedulerOptions == DispatchQueue.SchedulerOptions { - init(_ reducer: @escaping (StateStream.Value, EventStream.Value) -> StateStream.Value) { - self.init(reducer, on: DispatchQueue.main.eraseToAnyScheduler()) - } -} diff --git a/Sources/Combine/RunLoop+Executer.swift b/Sources/Combine/RunLoop+Executer.swift new file mode 100644 index 0000000..a6d3b81 --- /dev/null +++ b/Sources/Combine/RunLoop+Executer.swift @@ -0,0 +1,16 @@ +// +// RunLoop+Executer.swift +// +// +// Created by Thibault Wittemberg on 2020-08-04. +// + +import Foundation +import SpinCommon + +extension RunLoop: ExecuterDefinition { + public typealias Executer = RunLoop + public static func defaultSpinExecuter() -> Executer { + RunLoop.main + } +} diff --git a/Sources/Combine/Spin.swift b/Sources/Combine/Spin.swift index 42082c9..ad7f208 100644 --- a/Sources/Combine/Spin.swift +++ b/Sources/Combine/Spin.swift @@ -6,10 +6,22 @@ // import Combine +import Dispatch +import Foundation import SpinCommon @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public typealias Spin = AnySpin, AnyPublisher> +public typealias ScheduledSpin = AnySpin, AnyPublisher, Executer> + where Executer: ExecuterDefinition, Executer.Executer: Scheduler + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias Spin = ScheduledSpin + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias RunLoopSpin = ScheduledSpin + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias OperationQueueSpin = ScheduledSpin @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) public typealias CombineSpin = SpinCombine.Spin diff --git a/Sources/Combine/Spinner.swift b/Sources/Combine/Spinner.swift new file mode 100644 index 0000000..6125e40 --- /dev/null +++ b/Sources/Combine/Spinner.swift @@ -0,0 +1,22 @@ +// +// Spinner.swift +// +// +// Created by Thibault Wittemberg on 2020-08-04. +// + +import Dispatch +import Foundation +import SpinCommon + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias Spinner = AnySpinner + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias RunLoopSpinner = AnySpinner + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias OperationQueueSpinner = AnySpinner + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias CombineSpinner = SpinCombine.Spinner diff --git a/Sources/Combine/SwiftUISpin.swift b/Sources/Combine/SwiftUISpin.swift index 3ebf141..e2a3f31 100644 --- a/Sources/Combine/SwiftUISpin.swift +++ b/Sources/Combine/SwiftUISpin.swift @@ -13,17 +13,29 @@ import SwiftUI public typealias CombineSwiftUISpin = SpinCombine.SwiftUISpin @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public final class SwiftUISpin: Spin, StateRenderer, EventEmitter, ObservableObject { +public typealias SwiftUISpin = SpinCombine.ScheduledSwiftUISpin + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias RunLoopSwiftUISpin = SpinCombine.ScheduledSwiftUISpin + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias OperationQueueSwiftUISpin = SpinCombine.ScheduledSwiftUISpin + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public final class ScheduledSwiftUISpin: ScheduledSpin, StateRenderer, EventEmitter, ObservableObject +where Executer: ExecuterDefinition, Executer.Executer: Scheduler, State: Equatable { @Published public var state: State private let events = PassthroughSubject() - private var disposeBag = [AnyCancellable]() + private var subscriptions = [AnyCancellable]() - public init(spin: Spin) { + public init(spin: ScheduledSpin, extraRenderStateFunction: @escaping () -> Void = {}) { self.state = spin.initialState - super.init(initialState: spin.initialState, effects: spin.effects, scheduledReducer: spin.scheduledReducer) + super.init(initialState: spin.initialState, effects: spin.effects, reducer: spin.reducer, executer: spin.executer) let uiFeedback = Feedback(uiEffects: { [weak self] state in + guard state != self?.state else { return } self?.state = state + extraRenderStateFunction() }, { [weak self] in guard let `self` = self else { return Empty().eraseToAnyPublisher() } return self.events.eraseToAnyPublisher() @@ -32,14 +44,16 @@ public final class SwiftUISpin: Spin, StateRenderer, } public func emit(_ event: Event) { - self.events.send(event) + self.executer.schedule { [weak self] in + self?.events.send(event) + } } public func start() { - AnyPublisher.start(spin: self).disposed(by: &self.disposeBag) + AnyPublisher.start(spin: self).store(in: &self.subscriptions) } deinit { - self.disposeBag.forEach { $0.cancel() } + self.subscriptions.forEach { $0.cancel() } } } diff --git a/Sources/Combine/UISpin.swift b/Sources/Combine/UISpin.swift index 7a0cedb..5ef2870 100644 --- a/Sources/Combine/UISpin.swift +++ b/Sources/Combine/UISpin.swift @@ -13,8 +13,18 @@ import SwiftUI public typealias CombineUISpin = SpinCombine.UISpin @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public final class UISpin: Spin, StateRenderer, EventEmitter { - private var disposeBag = [AnyCancellable]() +public typealias UISpin = SpinCombine.ScheduledUISpin + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias RunLoopUISpin = SpinCombine.ScheduledUISpin + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public typealias OperationQueueUISpin = SpinCombine.ScheduledUISpin + +@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) +public final class ScheduledUISpin: ScheduledSpin, StateRenderer, EventEmitter +where Executer: ExecuterDefinition, Executer.Executer: Scheduler { + private var subscriptions = [AnyCancellable]() private let events = PassthroughSubject() private var externalRenderFunction: ((State) -> Void)? public var state: State { @@ -23,31 +33,34 @@ public final class UISpin: Spin, StateRenderer, Even } } - public init(spin: Spin) { + public init(spin: ScheduledSpin) { self.state = spin.initialState - super.init(initialState: spin.initialState, effects: spin.effects, scheduledReducer: spin.scheduledReducer) + super.init(initialState: spin.initialState, effects: spin.effects, reducer: spin.reducer, executer: spin.executer) let uiFeedback = Feedback(uiEffects: { [weak self] state in self?.state = state }, { [weak self] in guard let `self` = self else { return Empty().eraseToAnyPublisher() } return self.events.eraseToAnyPublisher() }, on: DispatchQueue.main.eraseToAnyScheduler()) + self.effects = [uiFeedback.effect] + spin.effects } - + public func render(on container: Container, using function: @escaping (Container) -> (State) -> Void) { self.externalRenderFunction = weakify(container: container, function: function) } - + public func emit(_ event: Event) { - self.events.send(event) + self.executer.schedule { [weak self] in + self?.events.send(event) + } } - + public func start() { - AnyPublisher.start(spin: self).disposed(by: &self.disposeBag) + AnyPublisher.start(spin: self).store(in: &self.subscriptions) } - + deinit { - self.disposeBag.forEach { $0.cancel() } + self.subscriptions.forEach { $0.cancel() } } } diff --git a/Sources/Common/AnySpin.swift b/Sources/Common/AnySpin.swift index d863ccb..1e52e46 100644 --- a/Sources/Common/AnySpin.swift +++ b/Sources/Common/AnySpin.swift @@ -4,78 +4,74 @@ // // Created by Thibault Wittemberg on 2019-12-29. // +import Foundation -open class AnySpin: SpinDefinition { - public var initialState: StateStream.Value +open class AnySpin: SpinDefinition { + public let initialState: StateStream.Value public var effects: [(StateStream) -> EventStream] - public var scheduledReducer: (EventStream) -> StateStream + public let reducer: (StateStream.Value, EventStream.Value) -> StateStream.Value + public let executer: Executer.Executer public init(initialState: StateStream.Value, effects: [(StateStream) -> EventStream], - scheduledReducer: @escaping (EventStream) -> StateStream) { + reducer: @escaping (StateStream.Value, EventStream.Value) -> StateStream.Value, + executer: Executer.Executer = Executer.defaultSpinExecuter()) { self.initialState = initialState self.effects = effects - self.scheduledReducer = scheduledReducer + self.reducer = reducer + self.executer = executer } - public convenience init(initialState: StateStream.Value, - feedback: FeedbackType, - reducer: ReducerType) - where - FeedbackType: FeedbackDefinition, - ReducerType: ReducerDefinition, - FeedbackType.StateStream == StateStream, - FeedbackType.EventStream == EventStream, - FeedbackType.StateStream.Value == StateStream.Value, - FeedbackType.StateStream == ReducerType.StateStream, - FeedbackType.EventStream == ReducerType.EventStream { + public convenience init( + initialState: StateStream.Value, + feedback: Feedback, + reducer: Reducer, + executeOn executer: Executer.Executer = Executer.defaultSpinExecuter() + ) where + Feedback: FeedbackDefinition, + Feedback.StateStream == StateStream, + Feedback.EventStream == EventStream { let effects = [feedback.effect] - self.init(initialState: initialState, effects: effects, scheduledReducer: reducer.scheduledReducer(with: initialState)) + self.init(initialState: initialState, effects: effects, reducer: reducer.reducer, executer: executer) } - public convenience init(initialState: StateStream.Value, - @FeedbackBuilder builder: () -> (FeedbackType, ReducerType)) - where - FeedbackType: FeedbackDefinition, - ReducerType: ReducerDefinition, - FeedbackType.StateStream == ReducerType.StateStream, - FeedbackType.EventStream == ReducerType.EventStream, - FeedbackType.StateStream == StateStream, - FeedbackType.EventStream == EventStream { + public convenience init( + initialState: StateStream.Value, + executeOn executer: Executer.Executer = Executer.defaultSpinExecuter(), + @FeedbackBuilder builder: () -> (Feedback, Reducer) + ) where + Feedback: FeedbackDefinition, + Feedback.StateStream == StateStream, + Feedback.EventStream == EventStream{ let (feedback, reducer) = builder() let effects = [feedback.effect] - self.init(initialState: initialState, effects: effects, scheduledReducer: reducer.scheduledReducer(with: initialState)) + self.init(initialState: initialState, effects: effects, reducer: reducer.reducer, executer: executer) } - public convenience init(initialState: StateStream.Value, - @FeedbackBuilder builder: () -> (FeedbackA, FeedbackB, ReducerType)) - where + public convenience init( + initialState: StateStream.Value, + executeOn executer: Executer.Executer = Executer.defaultSpinExecuter(), + @FeedbackBuilder builder: () -> (FeedbackA, FeedbackB, Reducer) + ) where FeedbackA: FeedbackDefinition, FeedbackB: FeedbackDefinition, - ReducerType: ReducerDefinition, - FeedbackA.StateStream == ReducerType.StateStream, - FeedbackA.EventStream == ReducerType.EventStream, - FeedbackA.StateStream == FeedbackB.StateStream, - FeedbackA.EventStream == FeedbackB.EventStream, FeedbackA.StateStream == StateStream, - FeedbackA.EventStream == EventStream { + FeedbackA.EventStream == EventStream, + FeedbackA.StateStream == FeedbackB.StateStream, + FeedbackA.EventStream == FeedbackB.EventStream { let (feedback1, feedback2, reducer) = builder() let effects = [feedback1.effect, feedback2.effect] - self.init(initialState: initialState, effects: effects, scheduledReducer: reducer.scheduledReducer(with: initialState)) + self.init(initialState: initialState, effects: effects, reducer: reducer.reducer, executer: executer) } - public convenience init(initialState: StateStream.Value, - @FeedbackBuilder builder: () -> ( FeedbackA, - FeedbackB, - FeedbackC, - ReducerType)) - where + public convenience init( + initialState: StateStream.Value, + executeOn executer: Executer.Executer = Executer.defaultSpinExecuter(), + @FeedbackBuilder builder: () -> (FeedbackA, FeedbackB, FeedbackC, Reducer) + ) where FeedbackA: FeedbackDefinition, FeedbackB: FeedbackDefinition, FeedbackC: FeedbackDefinition, - ReducerType: ReducerDefinition, - FeedbackA.StateStream == ReducerType.StateStream, - FeedbackA.EventStream == ReducerType.EventStream, FeedbackA.StateStream == FeedbackB.StateStream, FeedbackA.EventStream == FeedbackB.EventStream, FeedbackB.StateStream == FeedbackC.StateStream, @@ -84,23 +80,18 @@ open class AnySpin: Sp FeedbackA.EventStream == EventStream { let (feedback1, feedback2, feedback3, reducer) = builder() let effects = [feedback1.effect, feedback2.effect, feedback3.effect] - self.init(initialState: initialState, effects: effects, scheduledReducer: reducer.scheduledReducer(with: initialState)) + self.init(initialState: initialState, effects: effects, reducer: reducer.reducer, executer: executer) } - public convenience init(initialState: StateStream.Value, - @FeedbackBuilder builder: () -> ( FeedbackA, - FeedbackB, - FeedbackC, - FeedbackD, - ReducerType)) - where + public convenience init( + initialState: StateStream.Value, + executeOn executer: Executer.Executer = Executer.defaultSpinExecuter(), + @FeedbackBuilder builder: () -> (FeedbackA, FeedbackB, FeedbackC, FeedbackD, Reducer) + ) where FeedbackA: FeedbackDefinition, FeedbackB: FeedbackDefinition, FeedbackC: FeedbackDefinition, FeedbackD: FeedbackDefinition, - ReducerType: ReducerDefinition, - FeedbackA.StateStream == ReducerType.StateStream, - FeedbackA.EventStream == ReducerType.EventStream, FeedbackA.StateStream == FeedbackB.StateStream, FeedbackA.EventStream == FeedbackB.EventStream, FeedbackB.StateStream == FeedbackC.StateStream, @@ -111,25 +102,19 @@ open class AnySpin: Sp FeedbackA.EventStream == EventStream { let (feedback1, feedback2, feedback3, feedback4, reducer) = builder() let effects = [feedback1.effect, feedback2.effect, feedback3.effect, feedback4.effect] - self.init(initialState: initialState, effects: effects, scheduledReducer: reducer.scheduledReducer(with: initialState)) + self.init(initialState: initialState, effects: effects, reducer: reducer.reducer, executer: executer) } - public convenience init(initialState: StateStream.Value, - @FeedbackBuilder builder: () -> ( FeedbackA, - FeedbackB, - FeedbackC, - FeedbackD, - FeedbackE, - ReducerType)) - where + public convenience init( + initialState: StateStream.Value, + executeOn executer: Executer.Executer = Executer.defaultSpinExecuter(), + @FeedbackBuilder builder: () -> (FeedbackA, FeedbackB, FeedbackC, FeedbackD, FeedbackE, Reducer) + ) where FeedbackA: FeedbackDefinition, FeedbackB: FeedbackDefinition, FeedbackC: FeedbackDefinition, FeedbackD: FeedbackDefinition, FeedbackE: FeedbackDefinition, - ReducerType: ReducerDefinition, - FeedbackA.StateStream == ReducerType.StateStream, - FeedbackA.EventStream == ReducerType.EventStream, FeedbackA.StateStream == FeedbackB.StateStream, FeedbackA.EventStream == FeedbackB.EventStream, FeedbackB.StateStream == FeedbackC.StateStream, @@ -142,93 +127,86 @@ open class AnySpin: Sp FeedbackA.EventStream == EventStream { let (feedback1, feedback2, feedback3, feedback4, feedback5, reducer) = builder() let effects = [feedback1.effect, feedback2.effect, feedback3.effect, feedback4.effect, feedback5.effect] - self.init(initialState: initialState, effects: effects, scheduledReducer: reducer.scheduledReducer(with: initialState)) + self.init(initialState: initialState, effects: effects, reducer: reducer.reducer, executer: executer) } } @_functionBuilder public struct FeedbackBuilder { - public static func buildBlock(_ feedback: FeedbackType, _ reducer: ReducerType) - -> (FeedbackType, ReducerType) + public static func buildBlock( + _ feedback: Feedback, + _ reducer: Reducer + ) -> (Feedback, Reducer) where - FeedbackType: FeedbackDefinition, - ReducerType: ReducerDefinition, - FeedbackType.StateStream == ReducerType.StateStream, - FeedbackType.EventStream == ReducerType.EventStream { + Feedback: FeedbackDefinition { return (feedback, reducer) } - public static func buildBlock(_ feedbackA: FeedbackA, - _ feedbackB: FeedbackB, - _ reducer: ReducerType) - -> (FeedbackA, FeedbackB, ReducerType) + public static func buildBlock( + _ feedbackA: FeedbackA, + _ feedbackB: FeedbackB, + _ reducer: Reducer + ) -> (FeedbackA, FeedbackB, Reducer) where FeedbackA: FeedbackDefinition, FeedbackB: FeedbackDefinition, - ReducerType: ReducerDefinition, FeedbackA.StateStream == FeedbackB.StateStream, - FeedbackA.EventStream == FeedbackB.EventStream, - FeedbackA.StateStream == ReducerType.StateStream, - FeedbackA.EventStream == ReducerType.EventStream { + FeedbackA.EventStream == FeedbackB.EventStream { return (feedbackA, feedbackB, reducer) } - public static func buildBlock(_ feedbackA: FeedbackA, - _ feedbackB: FeedbackB, - _ feedbackC: FeedbackC, - _ reducer: ReducerType) - -> (FeedbackA, FeedbackB, FeedbackC, ReducerType) + public static func buildBlock( + _ feedbackA: FeedbackA, + _ feedbackB: FeedbackB, + _ feedbackC: FeedbackC, + _ reducer: Reducer + ) -> (FeedbackA, FeedbackB, FeedbackC, Reducer) where FeedbackA: FeedbackDefinition, FeedbackB: FeedbackDefinition, FeedbackC: FeedbackDefinition, - ReducerType: ReducerDefinition, FeedbackA.StateStream == FeedbackB.StateStream, FeedbackA.EventStream == FeedbackB.EventStream, FeedbackB.StateStream == FeedbackC.StateStream, - FeedbackB.EventStream == FeedbackC.EventStream, - FeedbackA.StateStream == ReducerType.StateStream, - FeedbackA.EventStream == ReducerType.EventStream { + FeedbackB.EventStream == FeedbackC.EventStream { return (feedbackA, feedbackB, feedbackC, reducer) } - public static func buildBlock(_ feedbackA: FeedbackA, - _ feedbackB: FeedbackB, - _ feedbackC: FeedbackC, - _ feedbackD: FeedbackD, - _ reducer: ReducerType) - -> (FeedbackA, FeedbackB, FeedbackC, FeedbackD, ReducerType) + public static func buildBlock( + _ feedbackA: FeedbackA, + _ feedbackB: FeedbackB, + _ feedbackC: FeedbackC, + _ feedbackD: FeedbackD, + _ reducer: Reducer + ) -> (FeedbackA, FeedbackB, FeedbackC, FeedbackD, Reducer) where FeedbackA: FeedbackDefinition, FeedbackB: FeedbackDefinition, FeedbackC: FeedbackDefinition, FeedbackD: FeedbackDefinition, - ReducerType: ReducerDefinition, FeedbackA.StateStream == FeedbackB.StateStream, FeedbackA.EventStream == FeedbackB.EventStream, FeedbackB.StateStream == FeedbackC.StateStream, FeedbackB.EventStream == FeedbackC.EventStream, FeedbackC.StateStream == FeedbackD.StateStream, - FeedbackC.EventStream == FeedbackD.EventStream, - FeedbackA.StateStream == ReducerType.StateStream, - FeedbackA.EventStream == ReducerType.EventStream { + FeedbackC.EventStream == FeedbackD.EventStream { return (feedbackA, feedbackB, feedbackC, feedbackD, reducer) } - public static func buildBlock(_ feedbackA: FeedbackA, - _ feedbackB: FeedbackB, - _ feedbackC: FeedbackC, - _ feedbackD: FeedbackD, - _ feedbackE: FeedbackE, - _ reducer: ReducerType) - -> (FeedbackA, FeedbackB, FeedbackC, FeedbackD, FeedbackE, ReducerType) + public static func buildBlock( + _ feedbackA: FeedbackA, + _ feedbackB: FeedbackB, + _ feedbackC: FeedbackC, + _ feedbackD: FeedbackD, + _ feedbackE: FeedbackE, + _ reducer: Reducer + ) -> (FeedbackA, FeedbackB, FeedbackC, FeedbackD, FeedbackE, Reducer) where FeedbackA: FeedbackDefinition, FeedbackB: FeedbackDefinition, FeedbackC: FeedbackDefinition, FeedbackD: FeedbackDefinition, FeedbackE: FeedbackDefinition, - ReducerType: ReducerDefinition, FeedbackA.StateStream == FeedbackB.StateStream, FeedbackA.EventStream == FeedbackB.EventStream, FeedbackB.StateStream == FeedbackC.StateStream, @@ -236,9 +214,7 @@ public struct FeedbackBuilder { FeedbackC.StateStream == FeedbackD.StateStream, FeedbackC.EventStream == FeedbackD.EventStream, FeedbackD.StateStream == FeedbackE.StateStream, - FeedbackD.EventStream == FeedbackE.EventStream, - FeedbackA.StateStream == ReducerType.StateStream, - FeedbackA.EventStream == ReducerType.EventStream { + FeedbackD.EventStream == FeedbackE.EventStream { return (feedbackA, feedbackB, feedbackC, feedbackD, feedbackE, reducer) } } diff --git a/Sources/Common/AnySpinner.swift b/Sources/Common/AnySpinner.swift new file mode 100644 index 0000000..eda1ec6 --- /dev/null +++ b/Sources/Common/AnySpinner.swift @@ -0,0 +1,64 @@ +// +// Spinner.swift +// +// +// Created by Thibault Wittemberg on 2019-12-29. +// + +import Foundation + +public class AnySpinner { + let initialState: State + let executer: Executer.Executer + + init (initialState state: State, executer: Executer.Executer) { + self.initialState = state + self.executer = executer + } + + public static func initialState(_ state: State, executeOn executer: Executer.Executer = Executer.defaultSpinExecuter()) -> AnySpinner { + return AnySpinner(initialState: state, executer: executer) + } + + public func feedback(_ feedback: Feedback) -> SpinnerFeedback + where + Feedback: FeedbackDefinition, + Feedback.StateStream.Value == State { + return SpinnerFeedback(initialState: self.initialState, + feedbacks: [feedback], + executer: self.executer) + } +} + +public class SpinnerFeedback { + let initialState: StateStream.Value + var effects: [(StateStream) -> EventStream] + let executer: Executer.Executer + + init (initialState state: StateStream.Value, + feedbacks: [Feedback], + executer: Executer.Executer) + where + Feedback.StateStream == StateStream, + Feedback.EventStream == EventStream { + self.initialState = state + self.effects = feedbacks.map { $0.effect } + self.executer = executer + } + + public func feedback(_ feedback: Feedback) -> SpinnerFeedback + where + Feedback: FeedbackDefinition, + Feedback.StateStream == StateStream, + Feedback.EventStream == EventStream { + self.effects.append(feedback.effect) + return self + } + + public func reducer(_ reducer: Reducer) -> AnySpin { + return AnySpin(initialState: self.initialState, + effects: self.effects, + reducer: reducer.reducer, + executer: self.executer) + } +} diff --git a/Sources/Common/ExecuterDefinition.swift b/Sources/Common/ExecuterDefinition.swift new file mode 100644 index 0000000..a8508ec --- /dev/null +++ b/Sources/Common/ExecuterDefinition.swift @@ -0,0 +1,11 @@ +// +// ExecuterDefinition.swift +// +// +// Created by Thibault Wittemberg on 2020-08-04. +// + +public protocol ExecuterDefinition { + associatedtype Executer + static func defaultSpinExecuter() -> Executer +} diff --git a/Sources/Common/Reducer.swift b/Sources/Common/Reducer.swift new file mode 100644 index 0000000..d5ad878 --- /dev/null +++ b/Sources/Common/Reducer.swift @@ -0,0 +1,17 @@ +// +// Reducer.swift +// +// +// Created by Thibault Wittemberg on 2019-12-29. +// + +/// A Reducer represents the way a reactive stream of `Event` can +/// sequentially mutate an initial `State` over time be executing a sequence of `Feedbacks` + +public struct Reducer { + public let reducer: (State, Event) -> State + + public init(_ reducer: @escaping (State, Event) -> State) { + self.reducer = reducer + } +} diff --git a/Sources/Common/ReducerDefinition.swift b/Sources/Common/ReducerDefinition.swift deleted file mode 100644 index 07c9a3f..0000000 --- a/Sources/Common/ReducerDefinition.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// ReducerDefinition.swift -// -// -// Created by Thibault Wittemberg on 2019-12-29. -// - -/// A Reducer represents the way a reactive stream of `Event` can -/// sequentially mutate an initial `State` over time be executing a sequence of `Feedbacks` -public protocol ReducerDefinition { - associatedtype StateStream: ReactiveStream - associatedtype EventStream: ReactiveStream - associatedtype Executer - - var reducer: (StateStream.Value, EventStream.Value) -> StateStream.Value { get } - var executer: Executer { get } - - init(_ reducer: @escaping (StateStream.Value, EventStream.Value) -> StateStream.Value, on executer: Executer) - func scheduledReducer(with initialState: StateStream.Value) -> (EventStream) -> StateStream -} diff --git a/Sources/Common/SpinDefinition.swift b/Sources/Common/SpinDefinition.swift index abd7103..bda7ada 100644 --- a/Sources/Common/SpinDefinition.swift +++ b/Sources/Common/SpinDefinition.swift @@ -11,8 +11,10 @@ public protocol SpinDefinition { associatedtype StateStream: ReactiveStream associatedtype EventStream: ReactiveStream + associatedtype Executer var initialState: StateStream.Value { get } var effects: [(StateStream) -> EventStream] { get } - var scheduledReducer: (EventStream) -> StateStream { get } + var reducer: (StateStream.Value, EventStream.Value) -> StateStream.Value { get } + var executer: Executer { get } } diff --git a/Sources/Common/Spinner.swift b/Sources/Common/Spinner.swift deleted file mode 100644 index c756ffb..0000000 --- a/Sources/Common/Spinner.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// Spinner.swift -// -// -// Created by Thibault Wittemberg on 2019-12-29. -// - -public class Spinner { - internal let initialState: State - - internal init (initialState state: State) { - self.initialState = state - } - - public static func initialState(_ state: State) -> Spinner { - return Spinner(initialState: state) - } - - public func feedback(_ feedback: FeedbackType) -> SpinnerFeedback< FeedbackType.StateStream, - FeedbackType.EventStream> - where FeedbackType.StateStream.Value == State { - return SpinnerFeedback< FeedbackType.StateStream, FeedbackType.EventStream>(initialState: self.initialState, - feedbacks: [feedback]) - } -} - -public class SpinnerFeedback { - internal let initialState: StateStream.Value - internal var effects: [(StateStream) -> EventStream] - - internal init (initialState state: StateStream.Value, - feedbacks: [FeedbackType]) - where - FeedbackType.StateStream == StateStream, - FeedbackType.EventStream == EventStream { - self.initialState = state - self.effects = feedbacks.map { $0.effect } - } - - public func feedback(_ feedback: NewFeedbackType) -> SpinnerFeedback - where - NewFeedbackType: FeedbackDefinition, - NewFeedbackType.StateStream == StateStream, - NewFeedbackType.EventStream == EventStream { - self.effects.append(feedback.effect) - return self - } - - public func reducer(_ reducer: ReducerType) -> AnySpin - where - ReducerType: ReducerDefinition, - ReducerType.StateStream == StateStream, - ReducerType.EventStream == EventStream { - return AnySpin(initialState: self.initialState, - effects: self.effects, - scheduledReducer: reducer.scheduledReducer(with: initialState)) - } -} diff --git a/Sources/ReactiveSwift/Disposable+DisposeBag.swift b/Sources/ReactiveSwift/Disposable+add.swift similarity index 63% rename from Sources/ReactiveSwift/Disposable+DisposeBag.swift rename to Sources/ReactiveSwift/Disposable+add.swift index 84d7ed2..b5abc53 100644 --- a/Sources/ReactiveSwift/Disposable+DisposeBag.swift +++ b/Sources/ReactiveSwift/Disposable+add.swift @@ -1,5 +1,5 @@ // -// Disposable+DisposeBag.swift +// Disposable+add.swift // // // Created by Thibault Wittemberg on 2019-12-31. @@ -8,7 +8,7 @@ import ReactiveSwift public extension Disposable { - func disposed(by disposable: CompositeDisposable) { + func add(to disposable: CompositeDisposable) { disposable.add(self) } } diff --git a/Sources/ReactiveSwift/Executer.swift b/Sources/ReactiveSwift/Executer.swift new file mode 100644 index 0000000..d427bec --- /dev/null +++ b/Sources/ReactiveSwift/Executer.swift @@ -0,0 +1,17 @@ +// +// Executer.swift +// +// +// Created by Thibault Wittemberg on 2020-08-05. +// + +import Foundation +import ReactiveSwift +import SpinCommon + +public class Executer: ExecuterDefinition { + public typealias Executer = Scheduler + public static func defaultSpinExecuter() -> Executer { + QueueScheduler(qos: .default, name: "io.warpfactor.spin.dispatch-queue.\(UUID())") + } +} diff --git a/Sources/ReactiveSwift/Reducer.swift b/Sources/ReactiveSwift/Reducer.swift deleted file mode 100644 index f8fbded..0000000 --- a/Sources/ReactiveSwift/Reducer.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// Reducer.swift -// -// -// Created by Thibault Wittemberg on 2019-12-31. -// - -import ReactiveSwift -import SpinCommon - -public typealias ReactiveReducer = SpinReactiveSwift.Reducer - -public struct Reducer: ReducerDefinition { - public typealias StateStream = SignalProducer - public typealias EventStream = SignalProducer - public typealias Executer = Scheduler - - public let reducer: (StateStream.Value, EventStream.Value) -> StateStream.Value - public let executer: Executer - - public init(_ reducer: @escaping (StateStream.Value, EventStream.Value) -> StateStream.Value, - on executer: Executer = QueueScheduler.main) { - self.reducer = reducer - self.executer = executer - } - - public func scheduledReducer(with initialState: StateStream.Value) -> (EventStream) -> StateStream { - return { events in - events - .observe(on: self.executer) - .scan(initialState, self.reducer) - } - } -} diff --git a/Sources/ReactiveSwift/SignalProducer+streamFromSpin.swift b/Sources/ReactiveSwift/SignalProducer+streamFromSpin.swift index e1dddbe..3cecf3d 100644 --- a/Sources/ReactiveSwift/SignalProducer+streamFromSpin.swift +++ b/Sources/ReactiveSwift/SignalProducer+streamFromSpin.swift @@ -15,16 +15,19 @@ public extension SignalProducer where Error == Never { guard let spin = spin else { return .empty } - let currentState = MutableProperty(spin.initialState) + let (signal, currentState) = Signal.pipe() // merging all the effects into one event stream - let eventStreams = spin.effects.map { $0(currentState.producer) } + let stateInputStream = signal.producer + let eventStreams = spin.effects.map { $0(stateInputStream) } let eventStream = SignalProducer.merge(eventStreams) - - return spin - .scheduledReducer(eventStream) + + return eventStream + .observe(on: spin.executer) + .scan(spin.initialState, spin.reducer) .prefix(value: spin.initialState) - .on(value: { currentState.swap($0) }) + .on(started: { currentState.send(value: spin.initialState) }) + .on(value: { currentState.send(value: $0) }) } } diff --git a/Sources/ReactiveSwift/Spin.swift b/Sources/ReactiveSwift/Spin.swift index 31f8563..be31394 100644 --- a/Sources/ReactiveSwift/Spin.swift +++ b/Sources/ReactiveSwift/Spin.swift @@ -8,6 +8,6 @@ import ReactiveSwift import SpinCommon -public typealias Spin = AnySpin, SignalProducer> +public typealias Spin = AnySpin, SignalProducer, Executer> public typealias ReactiveSpin = SpinReactiveSwift.Spin diff --git a/Sources/ReactiveSwift/Spinner.swift b/Sources/ReactiveSwift/Spinner.swift new file mode 100644 index 0000000..200f02e --- /dev/null +++ b/Sources/ReactiveSwift/Spinner.swift @@ -0,0 +1,12 @@ +// +// Spinner.swift +// +// +// Created by Thibault Wittemberg on 2020-08-05. +// + +import SpinCommon + +public typealias Spinner = AnySpinner + +public typealias ReactiveSpinner = SpinReactiveSwift.Spinner diff --git a/Sources/ReactiveSwift/SwiftUISpin.swift b/Sources/ReactiveSwift/SwiftUISpin.swift index ac28e7e..5dea76d 100644 --- a/Sources/ReactiveSwift/SwiftUISpin.swift +++ b/Sources/ReactiveSwift/SwiftUISpin.swift @@ -14,17 +14,20 @@ import SwiftUI public typealias ReactiveSwiftUISpin = SpinReactiveSwift.SwiftUISpin @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public final class SwiftUISpin: Spin, StateRenderer, EventEmitter, ObservableObject { +public final class SwiftUISpin: Spin, StateRenderer, EventEmitter, ObservableObject +where State: Equatable { @Published public var state: State private let (eventsProducer, eventsObserver) = Signal.pipe() - private let disposeBag = CompositeDisposable() + private let subscriptions = CompositeDisposable() - public init(spin: Spin) { + public init(spin: Spin, extraRenderStateFunction: @escaping () -> Void = {}) { self.state = spin.initialState - super.init(initialState: spin.initialState, effects: spin.effects, scheduledReducer: spin.scheduledReducer) + super.init(initialState: spin.initialState, effects: spin.effects, reducer: spin.reducer, executer: spin.executer) let uiFeedback = Feedback(uiEffects: { [weak self] state in + guard state != self?.state else { return } self?.state = state + extraRenderStateFunction() }, { [weak self] in guard let `self` = self else { return .empty } return self.eventsProducer.producer @@ -33,15 +36,17 @@ public final class SwiftUISpin: Spin, StateRenderer, } public func emit(_ event: Event) { - self.eventsObserver.send(value: event) + self.executer.schedule { [weak self] in + self?.eventsObserver.send(value: event) + } } public func start() { - SignalProducer.start(spin: self).disposed(by: self.disposeBag) + SignalProducer.start(spin: self).add(to: self.subscriptions) } deinit { - self.disposeBag.dispose() + self.subscriptions.dispose() } } #endif diff --git a/Sources/ReactiveSwift/UISpin.swift b/Sources/ReactiveSwift/UISpin.swift index 0e050f2..45034d7 100644 --- a/Sources/ReactiveSwift/UISpin.swift +++ b/Sources/ReactiveSwift/UISpin.swift @@ -11,7 +11,7 @@ import SpinCommon public typealias ReactiveUISpin = SpinReactiveSwift.UISpin public final class UISpin: Spin, StateRenderer, EventEmitter { - private let disposeBag = CompositeDisposable() + private let subscriptions = CompositeDisposable() private let (eventsProducer, eventsObserver) = Signal.pipe() private var externalRenderFunction: ((State) -> Void)? public var state: State { @@ -22,7 +22,7 @@ public final class UISpin: Spin, StateRenderer, Even public init(spin: Spin) { self.state = spin.initialState - super.init(initialState: spin.initialState, effects: spin.effects, scheduledReducer: spin.scheduledReducer) + super.init(initialState: spin.initialState, effects: spin.effects, reducer: spin.reducer, executer: spin.executer) let uiFeedback = Feedback(uiEffects: { [weak self] state in self?.state = state }, { [weak self] in @@ -37,14 +37,16 @@ public final class UISpin: Spin, StateRenderer, Even } public func emit(_ event: Event) { - self.eventsObserver.send(value: event) + self.executer.schedule { [weak self] in + self?.eventsObserver.send(value: event) + } } public func start() { - SignalProducer.start(spin: self).disposed(by: self.disposeBag) + SignalProducer.start(spin: self).add(to: self.subscriptions) } deinit { - self.disposeBag.dispose() + self.subscriptions.dispose() } } diff --git a/Sources/RxSwift/Executer.swift b/Sources/RxSwift/Executer.swift new file mode 100644 index 0000000..fce100e --- /dev/null +++ b/Sources/RxSwift/Executer.swift @@ -0,0 +1,17 @@ +// +// Executer.swift +// +// +// Created by Thibault Wittemberg on 2020-08-05. +// + +import Foundation +import RxSwift +import SpinCommon + +public class Executer: ExecuterDefinition { + public typealias Executer = ImmediateSchedulerType + public static func defaultSpinExecuter() -> Executer { + SerialDispatchQueueScheduler(internalSerialQueueName: "io.warpfactor.spin.dispatch-queue.\(UUID())") + } +} diff --git a/Sources/RxSwift/Gear.swift b/Sources/RxSwift/Gear.swift index e200587..4842c32 100644 --- a/Sources/RxSwift/Gear.swift +++ b/Sources/RxSwift/Gear.swift @@ -5,11 +5,11 @@ // Created by Thibault Wittemberg on 2020-07-26. // -import RxSwift import RxRelay +import RxSwift import SpinCommon -public typealias CombineGear = SpinRxSwift.Gear +public typealias RxGear = SpinRxSwift.Gear open class Gear: GearDefinition { @@ -17,11 +17,11 @@ open class Gear: GearDefinition { self.eventSubject.asObservable() } - let eventSubject = PublishSubject() + let eventSubject = PublishRelay() public init() {} open func propagate(event: Event) { - self.eventSubject.onNext(event) + self.eventSubject.accept(event) } } diff --git a/Sources/RxSwift/Observable+ReactiveStream.swift b/Sources/RxSwift/Observable+ReactiveStream.swift index 05b7041..a7448a7 100644 --- a/Sources/RxSwift/Observable+ReactiveStream.swift +++ b/Sources/RxSwift/Observable+ReactiveStream.swift @@ -14,7 +14,7 @@ extension Observable: ReactiveStream { public static func emptyStream() -> Self { guard let emptyStream = Observable.empty() as? Self else { - fatalError("Observable cannot be subclassed to be able to use the framework") + fatalError("Observable cannot be subclassed to be able to get an emptyStream()") } return emptyStream diff --git a/Sources/RxSwift/Observable+streamFromSpin.swift b/Sources/RxSwift/Observable+streamFromSpin.swift index 632537b..0f6af8a 100644 --- a/Sources/RxSwift/Observable+streamFromSpin.swift +++ b/Sources/RxSwift/Observable+streamFromSpin.swift @@ -5,6 +5,7 @@ // Created by Thibault Wittemberg on 2020-02-07. // +import RxRelay import RxSwift import SpinCommon @@ -14,16 +15,18 @@ public extension Observable { guard let spin = spin else { return .empty() } - let currentState = ReplaySubject.create(bufferSize: 1) + let currentState = BehaviorRelay(value: spin.initialState) // merging all the effects into one event stream - let eventStreams = spin.effects.map { $0(currentState.asObservable()) } + let stateInputStream = currentState.asObservable() + let eventStreams = spin.effects.map { $0(stateInputStream) } let eventStream = Observable.merge(eventStreams).catchError { _ in return .empty() } - return spin - .scheduledReducer(eventStream) - .startWith(spin.initialState) - .do(onNext: { currentState.onNext($0) }) + return eventStream + .subscribeOn(spin.executer) + .observeOn(spin.executer) + .scan(spin.initialState, accumulator: spin.reducer) + .do(onNext: { currentState.accept($0) }) } } diff --git a/Sources/RxSwift/Reducer.swift b/Sources/RxSwift/Reducer.swift deleted file mode 100644 index f2beb93..0000000 --- a/Sources/RxSwift/Reducer.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// Reducer.swift -// -// -// Created by Thibault Wittemberg on 2019-12-31. -// - -import RxRelay -import RxSwift -import SpinCommon - -public typealias RxReducer = SpinRxSwift.Reducer - -public struct Reducer: ReducerDefinition { - public typealias StateStream = Observable - public typealias EventStream = Observable - public typealias Executer = ImmediateSchedulerType - - public let reducer: (StateStream.Value, EventStream.Value) -> StateStream.Value - public let executer: Executer - - public init(_ reducer: @escaping (StateStream.Value, EventStream.Value) -> StateStream.Value, - on executer: Executer = CurrentThreadScheduler.instance) { - self.reducer = reducer - self.executer = executer - } - - public func scheduledReducer(with initialState: StateStream.Value) -> (EventStream) -> StateStream { - return { events in - events - .observeOn(self.executer) - .scan(initialState, accumulator: self.reducer) - } - } -} diff --git a/Sources/RxSwift/Spin.swift b/Sources/RxSwift/Spin.swift index f279a96..90d38e3 100644 --- a/Sources/RxSwift/Spin.swift +++ b/Sources/RxSwift/Spin.swift @@ -8,6 +8,6 @@ import RxSwift import SpinCommon -public typealias Spin = AnySpin, Observable> +public typealias Spin = AnySpin, Observable, Executer> public typealias RxSpin = SpinRxSwift.Spin diff --git a/Sources/RxSwift/Spinner.swift b/Sources/RxSwift/Spinner.swift new file mode 100644 index 0000000..1f06ae5 --- /dev/null +++ b/Sources/RxSwift/Spinner.swift @@ -0,0 +1,12 @@ +// +// Spinner.swift +// +// +// Created by Thibault Wittemberg on 2020-08-05. +// + +import SpinCommon + +public typealias Spinner = AnySpinner + +public typealias RxSpinner = SpinRxSwift.Spinner diff --git a/Sources/RxSwift/SwiftUISpin.swift b/Sources/RxSwift/SwiftUISpin.swift index a15e18f..138e7e5 100644 --- a/Sources/RxSwift/SwiftUISpin.swift +++ b/Sources/RxSwift/SwiftUISpin.swift @@ -15,28 +15,34 @@ import SwiftUI public typealias RxSwiftUISpin = SpinRxSwift.SwiftUISpin @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public final class SwiftUISpin: Spin, StateRenderer, EventEmitter, ObservableObject { +public final class SwiftUISpin: Spin, StateRenderer, EventEmitter, ObservableObject +where State: Equatable { @Published public var state: State private let events = PublishRelay() private let disposeBag = DisposeBag() - - public init(spin: Spin) { + + public init(spin: Spin, extraRenderStateFunction: @escaping () -> Void = {}) { self.state = spin.initialState - super.init(initialState: spin.initialState, effects: spin.effects, scheduledReducer: spin.scheduledReducer) + super.init(initialState: spin.initialState, effects: spin.effects, reducer: spin.reducer, executer: spin.executer) let uiFeedback = Feedback(uiEffects: { [weak self] state in + guard state != self?.state else { return } self?.state = state + extraRenderStateFunction() }, { [weak self] in guard let `self` = self else { return .empty() } return self.events.asObservable() }, on: MainScheduler.instance) self.effects = [uiFeedback.effect] + spin.effects } - + public func emit(_ event: Event) { - self.events.accept(event) + _ = self.executer.schedule(()) { [weak self] _ -> Disposable in + self?.events.accept(event) + return Disposables.create() + } } - + public func start() { Observable.start(spin: self).disposed(by: self.disposeBag) } diff --git a/Sources/RxSwift/UISpin.swift b/Sources/RxSwift/UISpin.swift index 6055f00..70fa5de 100644 --- a/Sources/RxSwift/UISpin.swift +++ b/Sources/RxSwift/UISpin.swift @@ -23,7 +23,7 @@ public final class UISpin: Spin, StateRenderer, Even public init(spin: Spin) { self.state = spin.initialState - super.init(initialState: spin.initialState, effects: spin.effects, scheduledReducer: spin.scheduledReducer) + super.init(initialState: spin.initialState, effects: spin.effects, reducer: spin.reducer, executer: spin.executer) let uiFeedback = Feedback(uiEffects: { [weak self] state in self?.state = state }, { [weak self] in @@ -38,7 +38,10 @@ public final class UISpin: Spin, StateRenderer, Even } public func emit(_ event: Event) { - self.events.accept(event) + _ = self.executer.schedule(()) { [weak self] _ -> Disposable in + self?.events.accept(event) + return Disposables.create() + } } public func start() { diff --git a/Spin.Swift.xcodeproj/project.pbxproj b/Spin.Swift.xcodeproj/project.pbxproj index 40ec24a..6b551b6 100644 --- a/Spin.Swift.xcodeproj/project.pbxproj +++ b/Spin.Swift.xcodeproj/project.pbxproj @@ -8,35 +8,29 @@ /* Begin PBXBuildFile section */ 742CD33D2443FC8900A9AB4C /* Spin_ReactiveSwift.h in Headers */ = {isa = PBXBuildFile; fileRef = 742CD33B2443FC8900A9AB4C /* Spin_ReactiveSwift.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 742CD3412443FCBF00A9AB4C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60442443BAC30054C286 /* Reducer.swift */; }; 742CD3422443FCBF00A9AB4C /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60452443BAC30054C286 /* UISpin.swift */; }; 742CD3432443FCBF00A9AB4C /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60462443BAC30054C286 /* Spin.swift */; }; 742CD3442443FCBF00A9AB4C /* SignalProducer+Deferred.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60472443BAC30054C286 /* SignalProducer+Deferred.swift */; }; - 742CD3452443FCBF00A9AB4C /* Disposable+DisposeBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60482443BAC30054C286 /* Disposable+DisposeBag.swift */; }; 742CD3462443FCBF00A9AB4C /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60492443BAC30054C286 /* SwiftUISpin.swift */; }; 742CD3472443FCBF00A9AB4C /* SignalProducer+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604A2443BAC30054C286 /* SignalProducer+ReactiveStream.swift */; }; 742CD3482443FCBF00A9AB4C /* SignalProducer+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604B2443BAC30054C286 /* SignalProducer+streamFromSpin.swift */; }; 742CD3492443FCBF00A9AB4C /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604C2443BAC30054C286 /* Feedback.swift */; }; 742CD3612443FD2300A9AB4C /* Spin_Swift.h in Headers */ = {isa = PBXBuildFile; fileRef = 742CD35F2443FD2300A9AB4C /* Spin_Swift.h */; settings = {ATTRIBUTES = (Public, ); }; }; 742CD3652443FD7100A9AB4C /* FeedbackDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604E2443BAC30054C286 /* FeedbackDefinition.swift */; }; - 742CD3662443FD7100A9AB4C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604F2443BAC30054C286 /* Spinner.swift */; }; 742CD3672443FD7100A9AB4C /* SpinDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60502443BAC30054C286 /* SpinDefinition.swift */; }; 742CD3682443FD7100A9AB4C /* ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60512443BAC30054C286 /* ReactiveStream.swift */; }; 742CD3692443FD7100A9AB4C /* Weakify.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60522443BAC30054C286 /* Weakify.swift */; }; 742CD36A2443FD7100A9AB4C /* AnySpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60532443BAC30054C286 /* AnySpin.swift */; }; 742CD36B2443FD7100A9AB4C /* StateRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60542443BAC30054C286 /* StateRenderer.swift */; }; - 742CD36C2443FD7100A9AB4C /* ReducerDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60552443BAC30054C286 /* ReducerDefinition.swift */; }; 742CD36D2443FD7100A9AB4C /* FeedbackDefinition+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60562443BAC30054C286 /* FeedbackDefinition+Default.swift */; }; 742CD36E2443FD7100A9AB4C /* EventEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60572443BAC30054C286 /* EventEmitter.swift */; }; 742CD37E2443FE9000A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; }; 742CD3832443FE9700A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; }; 742CD3872443FE9D00A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; platformFilter = ios; }; 742CD39D2443FF9E00A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; }; - 742CD3A22443FFA600A9AB4C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60442443BAC30054C286 /* Reducer.swift */; }; 742CD3A32443FFA600A9AB4C /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60452443BAC30054C286 /* UISpin.swift */; }; 742CD3A42443FFA600A9AB4C /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60462443BAC30054C286 /* Spin.swift */; }; 742CD3A52443FFA600A9AB4C /* SignalProducer+Deferred.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60472443BAC30054C286 /* SignalProducer+Deferred.swift */; }; - 742CD3A62443FFA600A9AB4C /* Disposable+DisposeBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60482443BAC30054C286 /* Disposable+DisposeBag.swift */; }; 742CD3A72443FFA600A9AB4C /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60492443BAC30054C286 /* SwiftUISpin.swift */; }; 742CD3A82443FFA600A9AB4C /* SignalProducer+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604A2443BAC30054C286 /* SignalProducer+ReactiveStream.swift */; }; 742CD3A92443FFA600A9AB4C /* SignalProducer+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604B2443BAC30054C286 /* SignalProducer+streamFromSpin.swift */; }; @@ -47,22 +41,18 @@ 742CD3AE2443FFC400A9AB4C /* Spin_RxSwift.h in Headers */ = {isa = PBXBuildFile; fileRef = 74FB609E2443BC990054C286 /* Spin_RxSwift.h */; settings = {ATTRIBUTES = (Public, ); }; }; 742CD3B82444BB4300A9AB4C /* Spin_ReactiveSwift.h in Headers */ = {isa = PBXBuildFile; fileRef = 742CD3B62444BB4300A9AB4C /* Spin_ReactiveSwift.h */; settings = {ATTRIBUTES = (Public, ); }; }; 742CD3C02444BB8A00A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; }; - 742CD3C52444BB9500A9AB4C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60442443BAC30054C286 /* Reducer.swift */; }; 742CD3C62444BB9500A9AB4C /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60452443BAC30054C286 /* UISpin.swift */; }; 742CD3C72444BB9500A9AB4C /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60462443BAC30054C286 /* Spin.swift */; }; 742CD3C82444BB9500A9AB4C /* SignalProducer+Deferred.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60472443BAC30054C286 /* SignalProducer+Deferred.swift */; }; - 742CD3C92444BB9500A9AB4C /* Disposable+DisposeBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60482443BAC30054C286 /* Disposable+DisposeBag.swift */; }; 742CD3CA2444BB9500A9AB4C /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60492443BAC30054C286 /* SwiftUISpin.swift */; }; 742CD3CB2444BB9500A9AB4C /* SignalProducer+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604A2443BAC30054C286 /* SignalProducer+ReactiveStream.swift */; }; 742CD3CC2444BB9500A9AB4C /* SignalProducer+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604B2443BAC30054C286 /* SignalProducer+streamFromSpin.swift */; }; 742CD3CD2444BB9500A9AB4C /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604C2443BAC30054C286 /* Feedback.swift */; }; 742CD3D72444BC1D00A9AB4C /* Spin_ReactiveSwift.h in Headers */ = {isa = PBXBuildFile; fileRef = 742CD3D52444BC1D00A9AB4C /* Spin_ReactiveSwift.h */; settings = {ATTRIBUTES = (Public, ); }; }; 742CD3DF2444BC3200A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; }; - 742CD3E42444BC5E00A9AB4C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60442443BAC30054C286 /* Reducer.swift */; }; 742CD3E52444BC5E00A9AB4C /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60452443BAC30054C286 /* UISpin.swift */; }; 742CD3E62444BC5E00A9AB4C /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60462443BAC30054C286 /* Spin.swift */; }; 742CD3E72444BC5E00A9AB4C /* SignalProducer+Deferred.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60472443BAC30054C286 /* SignalProducer+Deferred.swift */; }; - 742CD3E82444BC5E00A9AB4C /* Disposable+DisposeBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60482443BAC30054C286 /* Disposable+DisposeBag.swift */; }; 742CD3E92444BC5E00A9AB4C /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60492443BAC30054C286 /* SwiftUISpin.swift */; }; 742CD3EA2444BC5E00A9AB4C /* SignalProducer+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604A2443BAC30054C286 /* SignalProducer+ReactiveStream.swift */; }; 742CD3EB2444BC5E00A9AB4C /* SignalProducer+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB604B2443BAC30054C286 /* SignalProducer+streamFromSpin.swift */; }; @@ -71,10 +61,8 @@ 742CD3FA2444BC9C00A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; platformFilter = ios; }; 742CD3FF2444BCA700A9AB4C /* AnyPublisher+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60612443BAC30054C286 /* AnyPublisher+streamFromSpin.swift */; }; 742CD4002444BCA700A9AB4C /* AnyPublisher+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60622443BAC30054C286 /* AnyPublisher+ReactiveStream.swift */; }; - 742CD4012444BCA700A9AB4C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60632443BAC30054C286 /* Reducer.swift */; }; 742CD4022444BCA700A9AB4C /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60642443BAC30054C286 /* UISpin.swift */; }; 742CD4032444BCA700A9AB4C /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60652443BAC30054C286 /* Spin.swift */; }; - 742CD4042444BCA700A9AB4C /* AnyCancellable+DisposeBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60662443BAC30054C286 /* AnyCancellable+DisposeBag.swift */; }; 742CD4052444BCA700A9AB4C /* AnyScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60672443BAC30054C286 /* AnyScheduler.swift */; }; 742CD4062444BCA700A9AB4C /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60682443BAC30054C286 /* SwiftUISpin.swift */; }; 742CD4072444BCA700A9AB4C /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60692443BAC30054C286 /* Feedback.swift */; }; @@ -82,10 +70,8 @@ 742CD4152444BDE900A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; }; 742CD41A2444BE0800A9AB4C /* AnyPublisher+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60612443BAC30054C286 /* AnyPublisher+streamFromSpin.swift */; }; 742CD41B2444BE0800A9AB4C /* AnyPublisher+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60622443BAC30054C286 /* AnyPublisher+ReactiveStream.swift */; }; - 742CD41C2444BE0800A9AB4C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60632443BAC30054C286 /* Reducer.swift */; }; 742CD41D2444BE0800A9AB4C /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60642443BAC30054C286 /* UISpin.swift */; }; 742CD41E2444BE0800A9AB4C /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60652443BAC30054C286 /* Spin.swift */; }; - 742CD41F2444BE0800A9AB4C /* AnyCancellable+DisposeBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60662443BAC30054C286 /* AnyCancellable+DisposeBag.swift */; }; 742CD4202444BE0800A9AB4C /* AnyScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60672443BAC30054C286 /* AnyScheduler.swift */; }; 742CD4212444BE0800A9AB4C /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60682443BAC30054C286 /* SwiftUISpin.swift */; }; 742CD4222444BE0800A9AB4C /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60692443BAC30054C286 /* Feedback.swift */; }; @@ -95,19 +81,15 @@ 742CD4422444BE7100A9AB4C /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; }; 742CD4472444BE9400A9AB4C /* AnyPublisher+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60612443BAC30054C286 /* AnyPublisher+streamFromSpin.swift */; }; 742CD4482444BE9400A9AB4C /* AnyPublisher+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60622443BAC30054C286 /* AnyPublisher+ReactiveStream.swift */; }; - 742CD4492444BE9400A9AB4C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60632443BAC30054C286 /* Reducer.swift */; }; 742CD44A2444BE9400A9AB4C /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60642443BAC30054C286 /* UISpin.swift */; }; 742CD44B2444BE9400A9AB4C /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60652443BAC30054C286 /* Spin.swift */; }; - 742CD44C2444BE9400A9AB4C /* AnyCancellable+DisposeBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60662443BAC30054C286 /* AnyCancellable+DisposeBag.swift */; }; 742CD44D2444BE9400A9AB4C /* AnyScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60672443BAC30054C286 /* AnyScheduler.swift */; }; 742CD44E2444BE9400A9AB4C /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60682443BAC30054C286 /* SwiftUISpin.swift */; }; 742CD44F2444BE9400A9AB4C /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60692443BAC30054C286 /* Feedback.swift */; }; 742CD4502444BE9400A9AB4C /* AnyPublisher+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60612443BAC30054C286 /* AnyPublisher+streamFromSpin.swift */; }; 742CD4512444BE9400A9AB4C /* AnyPublisher+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60622443BAC30054C286 /* AnyPublisher+ReactiveStream.swift */; }; - 742CD4522444BE9400A9AB4C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60632443BAC30054C286 /* Reducer.swift */; }; 742CD4532444BE9400A9AB4C /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60642443BAC30054C286 /* UISpin.swift */; }; 742CD4542444BE9400A9AB4C /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60652443BAC30054C286 /* Spin.swift */; }; - 742CD4552444BE9400A9AB4C /* AnyCancellable+DisposeBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60662443BAC30054C286 /* AnyCancellable+DisposeBag.swift */; }; 742CD4562444BE9400A9AB4C /* AnyScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60672443BAC30054C286 /* AnyScheduler.swift */; }; 742CD4572444BE9400A9AB4C /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60682443BAC30054C286 /* SwiftUISpin.swift */; }; 742CD4582444BE9400A9AB4C /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60692443BAC30054C286 /* Feedback.swift */; }; @@ -132,27 +114,63 @@ 747B70962444F29C00C863C2 /* ReactiveSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 747B70952444F29C00C863C2 /* ReactiveSwift.framework */; }; 747B709A2444F2B000C863C2 /* ReactiveSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 747B70992444F2B000C863C2 /* ReactiveSwift.framework */; }; 747B709E2444F2C700C863C2 /* ReactiveSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 747B709D2444F2C700C863C2 /* ReactiveSwift.framework */; }; + 749A9C9424DCF9D70095032C /* ExecuterDefinition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9124DCF9D60095032C /* ExecuterDefinition.swift */; }; + 749A9C9524DCF9D70095032C /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9224DCF9D60095032C /* Reducer.swift */; }; + 749A9C9624DCF9D70095032C /* AnySpinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9324DCF9D70095032C /* AnySpinner.swift */; }; + 749A9C9B24DCFA130095032C /* Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9724DCFA0E0095032C /* Executer.swift */; }; + 749A9C9C24DCFA130095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9824DCFA0E0095032C /* Spinner.swift */; }; + 749A9C9D24DCFA140095032C /* Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9724DCFA0E0095032C /* Executer.swift */; }; + 749A9C9E24DCFA140095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9824DCFA0E0095032C /* Spinner.swift */; }; + 749A9C9F24DCFA150095032C /* Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9724DCFA0E0095032C /* Executer.swift */; }; + 749A9CA024DCFA150095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9824DCFA0E0095032C /* Spinner.swift */; }; + 749A9CA124DCFA150095032C /* Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9724DCFA0E0095032C /* Executer.swift */; }; + 749A9CA224DCFA150095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9C9824DCFA0E0095032C /* Spinner.swift */; }; + 749A9CA924DCFA340095032C /* Disposable+add.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA324DCFA2D0095032C /* Disposable+add.swift */; }; + 749A9CAA24DCFA340095032C /* Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA424DCFA2D0095032C /* Executer.swift */; }; + 749A9CAB24DCFA340095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA524DCFA2D0095032C /* Spinner.swift */; }; + 749A9CAC24DCFA350095032C /* Disposable+add.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA324DCFA2D0095032C /* Disposable+add.swift */; }; + 749A9CAD24DCFA350095032C /* Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA424DCFA2D0095032C /* Executer.swift */; }; + 749A9CAE24DCFA350095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA524DCFA2D0095032C /* Spinner.swift */; }; + 749A9CAF24DCFA350095032C /* Disposable+add.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA324DCFA2D0095032C /* Disposable+add.swift */; }; + 749A9CB024DCFA350095032C /* Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA424DCFA2D0095032C /* Executer.swift */; }; + 749A9CB124DCFA350095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA524DCFA2D0095032C /* Spinner.swift */; }; + 749A9CB224DCFA360095032C /* Disposable+add.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA324DCFA2D0095032C /* Disposable+add.swift */; }; + 749A9CB324DCFA360095032C /* Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA424DCFA2D0095032C /* Executer.swift */; }; + 749A9CB424DCFA360095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CA524DCFA2D0095032C /* Spinner.swift */; }; + 749A9CBD24DCFA4E0095032C /* DispatchQueue+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB724DCFA490095032C /* DispatchQueue+Executer.swift */; }; + 749A9CBE24DCFA4E0095032C /* OperationQueue+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB624DCFA490095032C /* OperationQueue+Executer.swift */; }; + 749A9CBF24DCFA4E0095032C /* RunLoop+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB824DCFA490095032C /* RunLoop+Executer.swift */; }; + 749A9CC024DCFA4E0095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB524DCFA490095032C /* Spinner.swift */; }; + 749A9CC124DCFA4F0095032C /* DispatchQueue+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB724DCFA490095032C /* DispatchQueue+Executer.swift */; }; + 749A9CC224DCFA4F0095032C /* OperationQueue+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB624DCFA490095032C /* OperationQueue+Executer.swift */; }; + 749A9CC324DCFA4F0095032C /* RunLoop+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB824DCFA490095032C /* RunLoop+Executer.swift */; }; + 749A9CC424DCFA4F0095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB524DCFA490095032C /* Spinner.swift */; }; + 749A9CC524DCFA4F0095032C /* DispatchQueue+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB724DCFA490095032C /* DispatchQueue+Executer.swift */; }; + 749A9CC624DCFA4F0095032C /* OperationQueue+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB624DCFA490095032C /* OperationQueue+Executer.swift */; }; + 749A9CC724DCFA4F0095032C /* RunLoop+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB824DCFA490095032C /* RunLoop+Executer.swift */; }; + 749A9CC824DCFA4F0095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB524DCFA490095032C /* Spinner.swift */; }; + 749A9CC924DCFA500095032C /* DispatchQueue+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB724DCFA490095032C /* DispatchQueue+Executer.swift */; }; + 749A9CCA24DCFA500095032C /* OperationQueue+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB624DCFA490095032C /* OperationQueue+Executer.swift */; }; + 749A9CCB24DCFA500095032C /* RunLoop+Executer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB824DCFA490095032C /* RunLoop+Executer.swift */; }; + 749A9CCC24DCFA500095032C /* Spinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 749A9CB524DCFA490095032C /* Spinner.swift */; }; 74CB98182444E4280017C2E9 /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; }; 74CB98222444EC900017C2E9 /* SpinCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 742CD35D2443FD2300A9AB4C /* SpinCommon.framework */; platformFilter = ios; }; 74CB98292444EEE20017C2E9 /* RxSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 74CB98272444EEE20017C2E9 /* RxSwift.framework */; }; 74CB982B2444EEE20017C2E9 /* RxRelay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 74CB98282444EEE20017C2E9 /* RxRelay.framework */; }; 74CB98302444EEFB0017C2E9 /* RxSwift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 74CB982E2444EEFB0017C2E9 /* RxSwift.framework */; }; 74CB98322444EEFB0017C2E9 /* RxRelay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 74CB982F2444EEFB0017C2E9 /* RxRelay.framework */; }; - 74FB60A42443BCDD0054C286 /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60592443BAC30054C286 /* Reducer.swift */; }; 74FB60A52443BCDD0054C286 /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605A2443BAC30054C286 /* UISpin.swift */; }; 74FB60A62443BCDD0054C286 /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605B2443BAC30054C286 /* Spin.swift */; }; 74FB60A72443BCDD0054C286 /* Observable+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605C2443BAC30054C286 /* Observable+ReactiveStream.swift */; }; 74FB60A82443BCDD0054C286 /* Observable+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605D2443BAC30054C286 /* Observable+streamFromSpin.swift */; }; 74FB60A92443BCDD0054C286 /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605E2443BAC30054C286 /* SwiftUISpin.swift */; }; 74FB60AA2443BCDD0054C286 /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605F2443BAC30054C286 /* Feedback.swift */; }; - 74FB60E52443C2790054C286 /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60592443BAC30054C286 /* Reducer.swift */; }; 74FB60E62443C2790054C286 /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605A2443BAC30054C286 /* UISpin.swift */; }; 74FB60E72443C2790054C286 /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605B2443BAC30054C286 /* Spin.swift */; }; 74FB60E82443C2790054C286 /* Observable+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605C2443BAC30054C286 /* Observable+ReactiveStream.swift */; }; 74FB60E92443C2790054C286 /* Observable+streamFromSpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605D2443BAC30054C286 /* Observable+streamFromSpin.swift */; }; 74FB60EA2443C2790054C286 /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605E2443BAC30054C286 /* SwiftUISpin.swift */; }; 74FB60EB2443C2790054C286 /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605F2443BAC30054C286 /* Feedback.swift */; }; - 74FB61182443C40D0054C286 /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60592443BAC30054C286 /* Reducer.swift */; }; 74FB61192443C40D0054C286 /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605A2443BAC30054C286 /* UISpin.swift */; }; 74FB611A2443C40D0054C286 /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605B2443BAC30054C286 /* Spin.swift */; }; 74FB611B2443C40D0054C286 /* Observable+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605C2443BAC30054C286 /* Observable+ReactiveStream.swift */; }; @@ -160,7 +178,6 @@ 74FB611D2443C40D0054C286 /* SwiftUISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605E2443BAC30054C286 /* SwiftUISpin.swift */; }; 74FB611E2443C40D0054C286 /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605F2443BAC30054C286 /* Feedback.swift */; }; 74FB61282443C42B0054C286 /* Spin_RxSwift.h in Headers */ = {isa = PBXBuildFile; fileRef = 74FB61262443C42B0054C286 /* Spin_RxSwift.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 74FB61332443C4610054C286 /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB60592443BAC30054C286 /* Reducer.swift */; }; 74FB61342443C4610054C286 /* UISpin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605A2443BAC30054C286 /* UISpin.swift */; }; 74FB61352443C4610054C286 /* Spin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605B2443BAC30054C286 /* Spin.swift */; }; 74FB61362443C4610054C286 /* Observable+ReactiveStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FB605C2443BAC30054C286 /* Observable+ReactiveStream.swift */; }; @@ -296,30 +313,37 @@ 747B70952444F29C00C863C2 /* ReactiveSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReactiveSwift.framework; path = Carthage/Build/Mac/ReactiveSwift.framework; sourceTree = ""; }; 747B70992444F2B000C863C2 /* ReactiveSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReactiveSwift.framework; path = Carthage/Build/watchOS/ReactiveSwift.framework; sourceTree = ""; }; 747B709D2444F2C700C863C2 /* ReactiveSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReactiveSwift.framework; path = Carthage/Build/tvOS/ReactiveSwift.framework; sourceTree = ""; }; + 749A9C9124DCF9D60095032C /* ExecuterDefinition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExecuterDefinition.swift; sourceTree = ""; }; + 749A9C9224DCF9D60095032C /* Reducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reducer.swift; sourceTree = ""; }; + 749A9C9324DCF9D70095032C /* AnySpinner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnySpinner.swift; sourceTree = ""; }; + 749A9C9724DCFA0E0095032C /* Executer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Executer.swift; sourceTree = ""; }; + 749A9C9824DCFA0E0095032C /* Spinner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spinner.swift; sourceTree = ""; }; + 749A9CA324DCFA2D0095032C /* Disposable+add.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Disposable+add.swift"; sourceTree = ""; }; + 749A9CA424DCFA2D0095032C /* Executer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Executer.swift; sourceTree = ""; }; + 749A9CA524DCFA2D0095032C /* Spinner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spinner.swift; sourceTree = ""; }; + 749A9CB524DCFA490095032C /* Spinner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spinner.swift; sourceTree = ""; }; + 749A9CB624DCFA490095032C /* OperationQueue+Executer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "OperationQueue+Executer.swift"; sourceTree = ""; }; + 749A9CB724DCFA490095032C /* DispatchQueue+Executer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+Executer.swift"; sourceTree = ""; }; + 749A9CB824DCFA490095032C /* RunLoop+Executer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "RunLoop+Executer.swift"; sourceTree = ""; }; 74CB98272444EEE20017C2E9 /* RxSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxSwift.framework; path = Carthage/Build/iOS/RxSwift.framework; sourceTree = ""; }; 74CB98282444EEE20017C2E9 /* RxRelay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxRelay.framework; path = Carthage/Build/iOS/RxRelay.framework; sourceTree = ""; }; 74CB982E2444EEFB0017C2E9 /* RxSwift.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxSwift.framework; path = Carthage/Build/Mac/RxSwift.framework; sourceTree = ""; }; 74CB982F2444EEFB0017C2E9 /* RxRelay.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxRelay.framework; path = Carthage/Build/Mac/RxRelay.framework; sourceTree = ""; }; - 74FB60442443BAC30054C286 /* Reducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reducer.swift; sourceTree = ""; }; 74FB60452443BAC30054C286 /* UISpin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UISpin.swift; sourceTree = ""; }; 74FB60462443BAC30054C286 /* Spin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spin.swift; sourceTree = ""; }; 74FB60472443BAC30054C286 /* SignalProducer+Deferred.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SignalProducer+Deferred.swift"; sourceTree = ""; }; - 74FB60482443BAC30054C286 /* Disposable+DisposeBag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Disposable+DisposeBag.swift"; sourceTree = ""; }; 74FB60492443BAC30054C286 /* SwiftUISpin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUISpin.swift; sourceTree = ""; }; 74FB604A2443BAC30054C286 /* SignalProducer+ReactiveStream.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SignalProducer+ReactiveStream.swift"; sourceTree = ""; }; 74FB604B2443BAC30054C286 /* SignalProducer+streamFromSpin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SignalProducer+streamFromSpin.swift"; sourceTree = ""; }; 74FB604C2443BAC30054C286 /* Feedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feedback.swift; sourceTree = ""; }; 74FB604E2443BAC30054C286 /* FeedbackDefinition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedbackDefinition.swift; sourceTree = ""; }; - 74FB604F2443BAC30054C286 /* Spinner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spinner.swift; sourceTree = ""; }; 74FB60502443BAC30054C286 /* SpinDefinition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpinDefinition.swift; sourceTree = ""; }; 74FB60512443BAC30054C286 /* ReactiveStream.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReactiveStream.swift; sourceTree = ""; }; 74FB60522443BAC30054C286 /* Weakify.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Weakify.swift; sourceTree = ""; }; 74FB60532443BAC30054C286 /* AnySpin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnySpin.swift; sourceTree = ""; }; 74FB60542443BAC30054C286 /* StateRenderer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateRenderer.swift; sourceTree = ""; }; - 74FB60552443BAC30054C286 /* ReducerDefinition.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReducerDefinition.swift; sourceTree = ""; }; 74FB60562443BAC30054C286 /* FeedbackDefinition+Default.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "FeedbackDefinition+Default.swift"; sourceTree = ""; }; 74FB60572443BAC30054C286 /* EventEmitter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventEmitter.swift; sourceTree = ""; }; - 74FB60592443BAC30054C286 /* Reducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reducer.swift; sourceTree = ""; }; 74FB605A2443BAC30054C286 /* UISpin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UISpin.swift; sourceTree = ""; }; 74FB605B2443BAC30054C286 /* Spin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spin.swift; sourceTree = ""; }; 74FB605C2443BAC30054C286 /* Observable+ReactiveStream.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Observable+ReactiveStream.swift"; sourceTree = ""; }; @@ -328,10 +352,8 @@ 74FB605F2443BAC30054C286 /* Feedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feedback.swift; sourceTree = ""; }; 74FB60612443BAC30054C286 /* AnyPublisher+streamFromSpin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AnyPublisher+streamFromSpin.swift"; sourceTree = ""; }; 74FB60622443BAC30054C286 /* AnyPublisher+ReactiveStream.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AnyPublisher+ReactiveStream.swift"; sourceTree = ""; }; - 74FB60632443BAC30054C286 /* Reducer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reducer.swift; sourceTree = ""; }; 74FB60642443BAC30054C286 /* UISpin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UISpin.swift; sourceTree = ""; }; 74FB60652443BAC30054C286 /* Spin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Spin.swift; sourceTree = ""; }; - 74FB60662443BAC30054C286 /* AnyCancellable+DisposeBag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AnyCancellable+DisposeBag.swift"; sourceTree = ""; }; 74FB60672443BAC30054C286 /* AnyScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnyScheduler.swift; sourceTree = ""; }; 74FB60682443BAC30054C286 /* SwiftUISpin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwiftUISpin.swift; sourceTree = ""; }; 74FB60692443BAC30054C286 /* Feedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feedback.swift; sourceTree = ""; }; @@ -605,12 +627,13 @@ 74FB60432443BAC30054C286 /* ReactiveSwift */ = { isa = PBXGroup; children = ( + 749A9CA324DCFA2D0095032C /* Disposable+add.swift */, + 749A9CA424DCFA2D0095032C /* Executer.swift */, + 749A9CA524DCFA2D0095032C /* Spinner.swift */, 744F1E9324CE205000997FC9 /* Gear.swift */, - 74FB60442443BAC30054C286 /* Reducer.swift */, 74FB60452443BAC30054C286 /* UISpin.swift */, 74FB60462443BAC30054C286 /* Spin.swift */, 74FB60472443BAC30054C286 /* SignalProducer+Deferred.swift */, - 74FB60482443BAC30054C286 /* Disposable+DisposeBag.swift */, 74FB60492443BAC30054C286 /* SwiftUISpin.swift */, 74FB604A2443BAC30054C286 /* SignalProducer+ReactiveStream.swift */, 74FB604B2443BAC30054C286 /* SignalProducer+streamFromSpin.swift */, @@ -622,15 +645,16 @@ 74FB604D2443BAC30054C286 /* Common */ = { isa = PBXGroup; children = ( + 749A9C9324DCF9D70095032C /* AnySpinner.swift */, + 749A9C9124DCF9D60095032C /* ExecuterDefinition.swift */, + 749A9C9224DCF9D60095032C /* Reducer.swift */, 744F1E9724CE206100997FC9 /* GearDefinition.swift */, 74FB604E2443BAC30054C286 /* FeedbackDefinition.swift */, - 74FB604F2443BAC30054C286 /* Spinner.swift */, 74FB60502443BAC30054C286 /* SpinDefinition.swift */, 74FB60512443BAC30054C286 /* ReactiveStream.swift */, 74FB60522443BAC30054C286 /* Weakify.swift */, 74FB60532443BAC30054C286 /* AnySpin.swift */, 74FB60542443BAC30054C286 /* StateRenderer.swift */, - 74FB60552443BAC30054C286 /* ReducerDefinition.swift */, 74FB60562443BAC30054C286 /* FeedbackDefinition+Default.swift */, 74FB60572443BAC30054C286 /* EventEmitter.swift */, ); @@ -640,8 +664,9 @@ 74FB60582443BAC30054C286 /* RxSwift */ = { isa = PBXGroup; children = ( + 749A9C9724DCFA0E0095032C /* Executer.swift */, + 749A9C9824DCFA0E0095032C /* Spinner.swift */, 744F1E9524CE205A00997FC9 /* Gear.swift */, - 74FB60592443BAC30054C286 /* Reducer.swift */, 74FB605A2443BAC30054C286 /* UISpin.swift */, 74FB605B2443BAC30054C286 /* Spin.swift */, 74FB605C2443BAC30054C286 /* Observable+ReactiveStream.swift */, @@ -655,13 +680,15 @@ 74FB60602443BAC30054C286 /* Combine */ = { isa = PBXGroup; children = ( + 749A9CB724DCFA490095032C /* DispatchQueue+Executer.swift */, + 749A9CB624DCFA490095032C /* OperationQueue+Executer.swift */, + 749A9CB824DCFA490095032C /* RunLoop+Executer.swift */, + 749A9CB524DCFA490095032C /* Spinner.swift */, 74FB60612443BAC30054C286 /* AnyPublisher+streamFromSpin.swift */, 74FB60622443BAC30054C286 /* AnyPublisher+ReactiveStream.swift */, 744F1E9124CE204100997FC9 /* Gear.swift */, - 74FB60632443BAC30054C286 /* Reducer.swift */, 74FB60642443BAC30054C286 /* UISpin.swift */, 74FB60652443BAC30054C286 /* Spin.swift */, - 74FB60662443BAC30054C286 /* AnyCancellable+DisposeBag.swift */, 74FB60672443BAC30054C286 /* AnyScheduler.swift */, 74FB60682443BAC30054C286 /* SwiftUISpin.swift */, 74FB60692443BAC30054C286 /* Feedback.swift */, @@ -1259,13 +1286,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 749A9CAB24DCFA340095032C /* Spinner.swift in Sources */, 742CD3472443FCBF00A9AB4C /* SignalProducer+ReactiveStream.swift in Sources */, - 742CD3412443FCBF00A9AB4C /* Reducer.swift in Sources */, 742CD3492443FCBF00A9AB4C /* Feedback.swift in Sources */, 742CD3462443FCBF00A9AB4C /* SwiftUISpin.swift in Sources */, 742CD3422443FCBF00A9AB4C /* UISpin.swift in Sources */, - 742CD3452443FCBF00A9AB4C /* Disposable+DisposeBag.swift in Sources */, + 749A9CA924DCFA340095032C /* Disposable+add.swift in Sources */, 744F1E9D24CE20E100997FC9 /* Gear.swift in Sources */, + 749A9CAA24DCFA340095032C /* Executer.swift in Sources */, 742CD3482443FCBF00A9AB4C /* SignalProducer+streamFromSpin.swift in Sources */, 742CD3442443FCBF00A9AB4C /* SignalProducer+Deferred.swift in Sources */, 742CD3432443FCBF00A9AB4C /* Spin.swift in Sources */, @@ -1276,14 +1304,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 749A9C9624DCF9D70095032C /* AnySpinner.swift in Sources */, 742CD3652443FD7100A9AB4C /* FeedbackDefinition.swift in Sources */, 742CD3672443FD7100A9AB4C /* SpinDefinition.swift in Sources */, + 749A9C9524DCF9D70095032C /* Reducer.swift in Sources */, 742CD36A2443FD7100A9AB4C /* AnySpin.swift in Sources */, 742CD36E2443FD7100A9AB4C /* EventEmitter.swift in Sources */, 742CD36D2443FD7100A9AB4C /* FeedbackDefinition+Default.swift in Sources */, 742CD3682443FD7100A9AB4C /* ReactiveStream.swift in Sources */, - 742CD3662443FD7100A9AB4C /* Spinner.swift in Sources */, - 742CD36C2443FD7100A9AB4C /* ReducerDefinition.swift in Sources */, + 749A9C9424DCF9D70095032C /* ExecuterDefinition.swift in Sources */, 742CD36B2443FD7100A9AB4C /* StateRenderer.swift in Sources */, 742CD3692443FD7100A9AB4C /* Weakify.swift in Sources */, 744F1E9824CE206100997FC9 /* GearDefinition.swift in Sources */, @@ -1294,13 +1323,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 749A9CAE24DCFA350095032C /* Spinner.swift in Sources */, 742CD3A82443FFA600A9AB4C /* SignalProducer+ReactiveStream.swift in Sources */, - 742CD3A22443FFA600A9AB4C /* Reducer.swift in Sources */, 742CD3AA2443FFA600A9AB4C /* Feedback.swift in Sources */, 742CD3A72443FFA600A9AB4C /* SwiftUISpin.swift in Sources */, 742CD3A32443FFA600A9AB4C /* UISpin.swift in Sources */, - 742CD3A62443FFA600A9AB4C /* Disposable+DisposeBag.swift in Sources */, + 749A9CAC24DCFA350095032C /* Disposable+add.swift in Sources */, 744F1E9E24CE20E100997FC9 /* Gear.swift in Sources */, + 749A9CAD24DCFA350095032C /* Executer.swift in Sources */, 742CD3A92443FFA600A9AB4C /* SignalProducer+streamFromSpin.swift in Sources */, 742CD3A52443FFA600A9AB4C /* SignalProducer+Deferred.swift in Sources */, 742CD3A42443FFA600A9AB4C /* Spin.swift in Sources */, @@ -1311,13 +1341,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 749A9CB124DCFA350095032C /* Spinner.swift in Sources */, 742CD3CB2444BB9500A9AB4C /* SignalProducer+ReactiveStream.swift in Sources */, - 742CD3C52444BB9500A9AB4C /* Reducer.swift in Sources */, 742CD3CD2444BB9500A9AB4C /* Feedback.swift in Sources */, 742CD3CA2444BB9500A9AB4C /* SwiftUISpin.swift in Sources */, 742CD3C62444BB9500A9AB4C /* UISpin.swift in Sources */, - 742CD3C92444BB9500A9AB4C /* Disposable+DisposeBag.swift in Sources */, + 749A9CAF24DCFA350095032C /* Disposable+add.swift in Sources */, 744F1E9F24CE20E200997FC9 /* Gear.swift in Sources */, + 749A9CB024DCFA350095032C /* Executer.swift in Sources */, 742CD3CC2444BB9500A9AB4C /* SignalProducer+streamFromSpin.swift in Sources */, 742CD3C82444BB9500A9AB4C /* SignalProducer+Deferred.swift in Sources */, 742CD3C72444BB9500A9AB4C /* Spin.swift in Sources */, @@ -1328,13 +1359,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 749A9CB424DCFA360095032C /* Spinner.swift in Sources */, 742CD3EA2444BC5E00A9AB4C /* SignalProducer+ReactiveStream.swift in Sources */, - 742CD3E42444BC5E00A9AB4C /* Reducer.swift in Sources */, 742CD3EC2444BC5E00A9AB4C /* Feedback.swift in Sources */, 742CD3E92444BC5E00A9AB4C /* SwiftUISpin.swift in Sources */, 742CD3E52444BC5E00A9AB4C /* UISpin.swift in Sources */, - 742CD3E82444BC5E00A9AB4C /* Disposable+DisposeBag.swift in Sources */, + 749A9CB224DCFA360095032C /* Disposable+add.swift in Sources */, 744F1EA024CE20E300997FC9 /* Gear.swift in Sources */, + 749A9CB324DCFA360095032C /* Executer.swift in Sources */, 742CD3EB2444BC5E00A9AB4C /* SignalProducer+streamFromSpin.swift in Sources */, 742CD3E72444BC5E00A9AB4C /* SignalProducer+Deferred.swift in Sources */, 742CD3E62444BC5E00A9AB4C /* Spin.swift in Sources */, @@ -1345,14 +1377,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 742CD4042444BCA700A9AB4C /* AnyCancellable+DisposeBag.swift in Sources */, + 749A9CBD24DCFA4E0095032C /* DispatchQueue+Executer.swift in Sources */, 742CD4062444BCA700A9AB4C /* SwiftUISpin.swift in Sources */, 742CD4072444BCA700A9AB4C /* Feedback.swift in Sources */, + 749A9CC024DCFA4E0095032C /* Spinner.swift in Sources */, + 749A9CBE24DCFA4E0095032C /* OperationQueue+Executer.swift in Sources */, 742CD4002444BCA700A9AB4C /* AnyPublisher+ReactiveStream.swift in Sources */, 742CD3FF2444BCA700A9AB4C /* AnyPublisher+streamFromSpin.swift in Sources */, 742CD4052444BCA700A9AB4C /* AnyScheduler.swift in Sources */, + 749A9CBF24DCFA4E0095032C /* RunLoop+Executer.swift in Sources */, 744F1E9924CE20D900997FC9 /* Gear.swift in Sources */, - 742CD4012444BCA700A9AB4C /* Reducer.swift in Sources */, 742CD4032444BCA700A9AB4C /* Spin.swift in Sources */, 742CD4022444BCA700A9AB4C /* UISpin.swift in Sources */, ); @@ -1362,14 +1396,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 742CD41F2444BE0800A9AB4C /* AnyCancellable+DisposeBag.swift in Sources */, + 749A9CC124DCFA4F0095032C /* DispatchQueue+Executer.swift in Sources */, 742CD4212444BE0800A9AB4C /* SwiftUISpin.swift in Sources */, 742CD4222444BE0800A9AB4C /* Feedback.swift in Sources */, + 749A9CC424DCFA4F0095032C /* Spinner.swift in Sources */, + 749A9CC224DCFA4F0095032C /* OperationQueue+Executer.swift in Sources */, 742CD41B2444BE0800A9AB4C /* AnyPublisher+ReactiveStream.swift in Sources */, 742CD41A2444BE0800A9AB4C /* AnyPublisher+streamFromSpin.swift in Sources */, 742CD4202444BE0800A9AB4C /* AnyScheduler.swift in Sources */, + 749A9CC324DCFA4F0095032C /* RunLoop+Executer.swift in Sources */, 744F1E9A24CE20DA00997FC9 /* Gear.swift in Sources */, - 742CD41C2444BE0800A9AB4C /* Reducer.swift in Sources */, 742CD41E2444BE0800A9AB4C /* Spin.swift in Sources */, 742CD41D2444BE0800A9AB4C /* UISpin.swift in Sources */, ); @@ -1379,14 +1415,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 742CD4552444BE9400A9AB4C /* AnyCancellable+DisposeBag.swift in Sources */, + 749A9CC524DCFA4F0095032C /* DispatchQueue+Executer.swift in Sources */, 742CD4572444BE9400A9AB4C /* SwiftUISpin.swift in Sources */, 742CD4582444BE9400A9AB4C /* Feedback.swift in Sources */, + 749A9CC824DCFA4F0095032C /* Spinner.swift in Sources */, + 749A9CC624DCFA4F0095032C /* OperationQueue+Executer.swift in Sources */, 742CD4512444BE9400A9AB4C /* AnyPublisher+ReactiveStream.swift in Sources */, 742CD4502444BE9400A9AB4C /* AnyPublisher+streamFromSpin.swift in Sources */, 742CD4562444BE9400A9AB4C /* AnyScheduler.swift in Sources */, + 749A9CC724DCFA4F0095032C /* RunLoop+Executer.swift in Sources */, 744F1E9B24CE20DB00997FC9 /* Gear.swift in Sources */, - 742CD4522444BE9400A9AB4C /* Reducer.swift in Sources */, 742CD4542444BE9400A9AB4C /* Spin.swift in Sources */, 742CD4532444BE9400A9AB4C /* UISpin.swift in Sources */, ); @@ -1396,14 +1434,16 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 742CD44C2444BE9400A9AB4C /* AnyCancellable+DisposeBag.swift in Sources */, + 749A9CC924DCFA500095032C /* DispatchQueue+Executer.swift in Sources */, 742CD44E2444BE9400A9AB4C /* SwiftUISpin.swift in Sources */, 742CD44F2444BE9400A9AB4C /* Feedback.swift in Sources */, + 749A9CCC24DCFA500095032C /* Spinner.swift in Sources */, + 749A9CCA24DCFA500095032C /* OperationQueue+Executer.swift in Sources */, 742CD4482444BE9400A9AB4C /* AnyPublisher+ReactiveStream.swift in Sources */, 742CD4472444BE9400A9AB4C /* AnyPublisher+streamFromSpin.swift in Sources */, 742CD44D2444BE9400A9AB4C /* AnyScheduler.swift in Sources */, + 749A9CCB24DCFA500095032C /* RunLoop+Executer.swift in Sources */, 744F1E9C24CE20DB00997FC9 /* Gear.swift in Sources */, - 742CD4492444BE9400A9AB4C /* Reducer.swift in Sources */, 742CD44B2444BE9400A9AB4C /* Spin.swift in Sources */, 742CD44A2444BE9400A9AB4C /* UISpin.swift in Sources */, ); @@ -1413,13 +1453,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 74FB60A42443BCDD0054C286 /* Reducer.swift in Sources */, 74FB60A92443BCDD0054C286 /* SwiftUISpin.swift in Sources */, + 749A9C9C24DCFA130095032C /* Spinner.swift in Sources */, 74FB60A82443BCDD0054C286 /* Observable+streamFromSpin.swift in Sources */, 74FB60A72443BCDD0054C286 /* Observable+ReactiveStream.swift in Sources */, 744F1EA124CE20F400997FC9 /* Gear.swift in Sources */, 74FB60A52443BCDD0054C286 /* UISpin.swift in Sources */, 74FB60AA2443BCDD0054C286 /* Feedback.swift in Sources */, + 749A9C9B24DCFA130095032C /* Executer.swift in Sources */, 74FB60A62443BCDD0054C286 /* Spin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1429,12 +1470,13 @@ buildActionMask = 2147483647; files = ( 74FB60EA2443C2790054C286 /* SwiftUISpin.swift in Sources */, - 74FB60E52443C2790054C286 /* Reducer.swift in Sources */, + 749A9C9E24DCFA140095032C /* Spinner.swift in Sources */, 74FB60EB2443C2790054C286 /* Feedback.swift in Sources */, 74FB60E82443C2790054C286 /* Observable+ReactiveStream.swift in Sources */, 744F1EA224CE20F500997FC9 /* Gear.swift in Sources */, 74FB60E92443C2790054C286 /* Observable+streamFromSpin.swift in Sources */, 74FB60E72443C2790054C286 /* Spin.swift in Sources */, + 749A9C9D24DCFA140095032C /* Executer.swift in Sources */, 74FB60E62443C2790054C286 /* UISpin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1444,12 +1486,13 @@ buildActionMask = 2147483647; files = ( 74FB611D2443C40D0054C286 /* SwiftUISpin.swift in Sources */, - 74FB61182443C40D0054C286 /* Reducer.swift in Sources */, + 749A9CA024DCFA150095032C /* Spinner.swift in Sources */, 74FB611E2443C40D0054C286 /* Feedback.swift in Sources */, 74FB611B2443C40D0054C286 /* Observable+ReactiveStream.swift in Sources */, 744F1EA324CE20F500997FC9 /* Gear.swift in Sources */, 74FB611C2443C40D0054C286 /* Observable+streamFromSpin.swift in Sources */, 74FB611A2443C40D0054C286 /* Spin.swift in Sources */, + 749A9C9F24DCFA150095032C /* Executer.swift in Sources */, 74FB61192443C40D0054C286 /* UISpin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1458,13 +1501,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 74FB61332443C4610054C286 /* Reducer.swift in Sources */, 74FB61382443C4610054C286 /* SwiftUISpin.swift in Sources */, + 749A9CA224DCFA150095032C /* Spinner.swift in Sources */, 74FB61372443C4610054C286 /* Observable+streamFromSpin.swift in Sources */, 74FB61362443C4610054C286 /* Observable+ReactiveStream.swift in Sources */, 744F1EA424CE20F600997FC9 /* Gear.swift in Sources */, 74FB61342443C4610054C286 /* UISpin.swift in Sources */, 74FB61392443C4610054C286 /* Feedback.swift in Sources */, + 749A9CA124DCFA150095032C /* Executer.swift in Sources */, 74FB61352443C4610054C286 /* Spin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1560,7 +1604,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinReactiveSwift; PRODUCT_NAME = SpinReactiveSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1596,7 +1640,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinReactiveSwift; PRODUCT_NAME = SpinReactiveSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1630,7 +1674,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCommon; PRODUCT_NAME = SpinCommon; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1661,7 +1705,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCommon; PRODUCT_NAME = SpinCommon; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1696,7 +1740,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinReactiveSwift; PRODUCT_NAME = SpinReactiveSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1730,7 +1774,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinReactiveSwift; PRODUCT_NAME = SpinReactiveSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1764,7 +1808,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinReactiveSwift; PRODUCT_NAME = SpinReactiveSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1800,7 +1844,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinReactiveSwift; PRODUCT_NAME = SpinReactiveSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1836,7 +1880,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinReactiveSwift; PRODUCT_NAME = SpinReactiveSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1871,7 +1915,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinReactiveSwift; PRODUCT_NAME = SpinReactiveSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1902,7 +1946,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCombine; PRODUCT_NAME = SpinCombine; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1933,7 +1977,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCombine; PRODUCT_NAME = SpinCombine; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1966,7 +2010,7 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCombine; PRODUCT_NAME = SpinCombine; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1995,7 +2039,7 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCombine; PRODUCT_NAME = SpinCombine; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2024,7 +2068,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCombine; PRODUCT_NAME = SpinCombine; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2055,7 +2099,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCombine; PRODUCT_NAME = SpinCombine; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2086,7 +2130,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCombine; PRODUCT_NAME = SpinCombine; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2116,7 +2160,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinCombine; PRODUCT_NAME = SpinCombine; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2271,7 +2315,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinRxSwift; PRODUCT_NAME = SpinRxSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2307,7 +2351,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinRxSwift; PRODUCT_NAME = SpinRxSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2345,7 +2389,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinRxSwift; PRODUCT_NAME = SpinRxSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2379,7 +2423,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinRxSwift; PRODUCT_NAME = SpinRxSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2413,7 +2457,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinRxSwift; PRODUCT_NAME = SpinRxSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2449,7 +2493,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinRxSwift; PRODUCT_NAME = SpinRxSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2485,7 +2529,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinRxSwift; PRODUCT_NAME = SpinRxSwift; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2520,7 +2564,7 @@ "@loader_path/Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 10.10; - MARKETING_VERSION = 0.17; + MARKETING_VERSION = 0.18.0; PRODUCT_BUNDLE_IDENTIFIER = com.github.spinners.SpinRxSwift; PRODUCT_NAME = SpinRxSwift; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/SpinCombine.podspec b/SpinCombine.podspec index 09ef602..b93a680 100644 --- a/SpinCombine.podspec +++ b/SpinCombine.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SpinCombine" - s.version = "0.17.0" + s.version = "0.18.0" s.swift_version = "5.2.2" s.summary = "Spin is a tool whose only purpose is to help you build feedback loops called Spins" s.description = <<-DESC @@ -22,6 +22,6 @@ Spin is a tool to build feedback loops within a Swift based application allowing s.source_files = 'Sources/Combine/*.swift' - s.dependency 'SpinCommon', '>= 0.17.0' + s.dependency 'SpinCommon', '>= 0.18.0' end diff --git a/SpinCommon.podspec b/SpinCommon.podspec index 8b5ab8c..7072ebc 100644 --- a/SpinCommon.podspec +++ b/SpinCommon.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SpinCommon" - s.version = "0.17.0" + s.version = "0.18.0" s.swift_version = "5.2.2" s.summary = "Spin is a tool whose only purpose is to help you build feedback loops called Spins" s.description = <<-DESC diff --git a/SpinReactiveSwift.podspec b/SpinReactiveSwift.podspec index 86ec394..86ea319 100644 --- a/SpinReactiveSwift.podspec +++ b/SpinReactiveSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SpinReactiveSwift" - s.version = "0.17.0" + s.version = "0.18.0" s.swift_version = "5.2.2" s.summary = "Spin is a tool whose only purpose is to help you build feedback loops called Spins" s.description = <<-DESC @@ -22,7 +22,7 @@ Spin is a tool to build feedback loops within a Swift based application allowing s.source_files = 'Sources/ReactiveSwift/*.swift' - s.dependency 'ReactiveSwift', '>= 6.2.1' - s.dependency 'SpinCommon', '>= 0.17.0' + s.dependency 'ReactiveSwift', '>= 6.3.0' + s.dependency 'SpinCommon', '>= 0.18.0' end diff --git a/SpinRxSwift.podspec b/SpinRxSwift.podspec index 36ca249..6deced3 100644 --- a/SpinRxSwift.podspec +++ b/SpinRxSwift.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SpinRxSwift" - s.version = "0.17.0" + s.version = "0.18.0" s.swift_version = "5.2.2" s.summary = "Spin is a tool whose only purpose is to help you build feedback loops called Spins" s.description = <<-DESC @@ -22,8 +22,8 @@ Spin is a tool to build feedback loops within a Swift based application allowing s.source_files = 'Sources/RxSwift/*.swift' - s.dependency 'RxSwift', '>= 5.1.0' - s.dependency 'RxRelay', '>= 5.1.0' - s.dependency 'SpinCommon', '>= 0.17.0' + s.dependency 'RxSwift', '>= 5.1.1' + s.dependency 'RxRelay', '>= 5.1.1' + s.dependency 'SpinCommon', '>= 0.18.0' end diff --git a/Tests/CombineTests/AnyCancellable+DisposeBagTests.swift b/Tests/CombineTests/AnyCancellable+DisposeBagTests.swift deleted file mode 100644 index 40ad4d5..0000000 --- a/Tests/CombineTests/AnyCancellable+DisposeBagTests.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// AnyCancellable+DisposeBagTests.swift -// -// -// Created by Thibault Wittemberg on 2019-12-30. -// - -import Combine -import SpinCombine -import XCTest - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -final class AnyCancellable_DisposeBagTests: XCTestCase { - func test_disposedBy_adds_the_expected_number_of_cancellables() { - // Given: an empty disposeBag and several cancellables - var disposeBag = [AnyCancellable]() - - let cancellableA = AnyCancellable { () in return () } - let cancellableB = AnyCancellable { () in return () } - - // When: using a disposeBag to store the cancellables - cancellableA.disposed(by: &disposeBag) - cancellableB.disposed(by: &disposeBag) - - // Then: the disposeBag is filled in with the expected cancellables - XCTAssertEqual(disposeBag.count, 2) - XCTAssertEqual(disposeBag[0].hashValue, cancellableA.hashValue) - XCTAssertEqual(disposeBag[1].hashValue, cancellableB.hashValue) - } -} diff --git a/Tests/CombineTests/AnyPublisher+ReactiveStreamTests.swift b/Tests/CombineTests/AnyPublisher+ReactiveStreamTests.swift index 21288b9..861f9f3 100644 --- a/Tests/CombineTests/AnyPublisher+ReactiveStreamTests.swift +++ b/Tests/CombineTests/AnyPublisher+ReactiveStreamTests.swift @@ -12,7 +12,7 @@ import XCTest @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) final class AnyPublisher_ReactiveStreamTests: XCTestCase { - private var disposeBag = [AnyCancellable]() + private var subscriptions = [AnyCancellable]() func test_reactive_stream_is_subscribed_when_spin_is_called() { @@ -28,7 +28,7 @@ final class AnyPublisher_ReactiveStreamTests: XCTestCase { exp.fulfill() }) .subscribe() - .disposed(by: &self.disposeBag) + .store(in: &self.subscriptions) waitForExpectations(timeout: 5) diff --git a/Tests/CombineTests/AnyPublisher+streamFromSpinTests.swift b/Tests/CombineTests/AnyPublisher+streamFromSpinTests.swift index d857934..6b2cef0 100644 --- a/Tests/CombineTests/AnyPublisher+streamFromSpinTests.swift +++ b/Tests/CombineTests/AnyPublisher+streamFromSpinTests.swift @@ -7,26 +7,29 @@ import Combine import SpinCombine +import SpinCommon import XCTest @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) final class AnyPublisher_streamFromSpinTests: XCTestCase { + private var subscriptions = [AnyCancellable]() func test_initialState_is_the_first_state_given_to_the_effects() throws { + let exp = expectation(description: "Effects") // Given: 2 feedbacks and 1 reducer assembled in a Spin with an initialState let initialState = "initialState" - var receivedInitialStateInEffectA = "" - var receivedInitialStateInEffectB = "" + var receivedStatesA = [String]() + var receivedStatesB = [String]() let feedbackA = Feedback(effect: { states in states.map { state -> String in - receivedInitialStateInEffectA = state + receivedStatesA.append(state) return "event" }.eraseToAnyPublisher() }) let feedbackB = Feedback(effect: { states in return states.map { state -> String in - receivedInitialStateInEffectB = state + receivedStatesB.append(state) return "event" }.eraseToAnyPublisher() }) @@ -41,22 +44,25 @@ final class AnyPublisher_streamFromSpinTests: XCTestCase { } // When: producing/subscribing to a stream based on the Spin - let recorder = AnyPublisher + AnyPublisher .stream(from: spin) - .output(in: (0..<1)) - .record() + .output(in: (0...0)) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { _ in }) + .store(in: &self.subscriptions) - _ = try wait(for: recorder.elements, timeout: 5) + waitForExpectations(timeout: 0.5) // Then: the feedback's effects receive the initial state - XCTAssertEqual(receivedInitialStateInEffectA, initialState) - XCTAssertEqual(receivedInitialStateInEffectB, initialState) + XCTAssertEqual(receivedStatesA[0], initialState) + XCTAssertEqual(receivedStatesB[0], initialState) } func test_initialState_is_the_state_given_to_the_reducer() throws { + let exp = expectation(description: "Reducer") + // Given: 1 feedback and 1 reducer assembled in a Spin with an initialState let initialState = "initialState" - var receivedInitialStateInReducer = "" + var receivedStatesInReducer = [String]() let feedbackA = Feedback(effect: { states in states.map { state -> String in @@ -65,7 +71,7 @@ final class AnyPublisher_streamFromSpinTests: XCTestCase { }) let reducer = Reducer({ state, _ in - receivedInitialStateInReducer = state + receivedStatesInReducer.append(state) return "newState" }) @@ -75,14 +81,15 @@ final class AnyPublisher_streamFromSpinTests: XCTestCase { } // When: producing/subscribing to a stream based on the Spin - let recorder = AnyPublisher + AnyPublisher .stream(from: spin) - .output(in: (0...1)) - .record() + .output(in: (0...0)) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { _ in }) + .store(in: &self.subscriptions) - _ = try wait(for: recorder.elements, timeout: 5) + waitForExpectations(timeout: 0.5) // Then: the reducer receives the initial state - XCTAssertEqual(receivedInitialStateInReducer, initialState) + XCTAssertEqual(receivedStatesInReducer[0], initialState) } } diff --git a/Tests/CombineTests/CombineExpectations/PublisherExpectation.swift b/Tests/CombineTests/CombineExpectations/PublisherExpectation.swift deleted file mode 100644 index fdd1366..0000000 --- a/Tests/CombineTests/CombineExpectations/PublisherExpectation.swift +++ /dev/null @@ -1,60 +0,0 @@ -import XCTest - -/// A name space for publisher expectations -public enum PublisherExpectations { } - -/// The protocol for publisher expectations. -/// -/// You can build publisher expectations from Recorder returned by the -/// `Publisher.record()` method. For example: -/// -/// // SUCCESS: no timeout, no error -/// func testArrayPublisherPublishesArrayElements() throws { -/// let publisher = ["foo", "bar", "baz"].publisher -/// let recorder = publisher.record() -/// let expectation = recorder.elements -/// let elements = try wait(for: expectation, timeout: 1) -/// XCTAssertEqual(elements, ["foo", "bar", "baz"]) -/// } -public protocol PublisherExpectation { - associatedtype Output - - /// :nodoc: - func setup(_ expectation: XCTestExpectation) - - /// :nodoc: - func expectedValue() throws -> Output -} - -extension XCTestCase { - /// Waits for the publisher expectation to fulfill, and returns the - /// expected value. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayPublisherPublishesArrayElements() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// let elements = try wait(for: recorder.elements, timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar", "baz"]) - /// } - /// - /// - parameter publisherExpectation: The publisher expectation. - /// - parameter timeout: The number of seconds within which the expectation - /// must be fulfilled. - /// - parameter description: A string to display in the test log for the - /// expectation, to help diagnose failures. - /// - throws: An error if the expectation fails. - public func wait( - for publisherExpectation: R, - timeout: TimeInterval, - description: String = "") - throws -> R.Output - { - let expectation = self.expectation(description: description) - publisherExpectation.setup(expectation) - wait(for: [expectation], timeout: timeout) - return try publisherExpectation.expectedValue() - } -} diff --git a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Finished.swift b/Tests/CombineTests/CombineExpectations/PublisherExpectations/Finished.swift deleted file mode 100644 index b1aef8f..0000000 --- a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Finished.swift +++ /dev/null @@ -1,80 +0,0 @@ -import XCTest - -// The Finished expectation waits for the publisher to complete, and throws an -// error if and only if the publisher fails with an error. -// -// It is not derived from the Recording expectation, because Finished does not -// throw RecordingError.notCompleted if the publisher does not complete on time. -// It only triggers a timeout test failure. -// -// This allows to write tests for publishers that should not complete: -// -// // SUCCESS: no timeout, no error -// func testPassthroughSubjectDoesNotFinish() throws { -// let publisher = PassthroughSubject() -// let recorder = publisher.record() -// try wait(for: recorder.finished.inverted, timeout: 1) -// } -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension PublisherExpectations { - /// A publisher expectation which waits for the recorded publisher - /// to complete. - /// - /// When waiting for this expectation, the publisher error is thrown if the - /// publisher fails. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayPublisherFinishesWithoutError() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// try wait(for: recorder.finished, timeout: 1) - /// } - /// - /// This publisher expectation can be inverted: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectDoesNotFinish() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// try wait(for: recorder.finished.inverted, timeout: 1) - /// } - public struct Finished: PublisherExpectation { - let recorder: Recorder - - public func setup(_ expectation: XCTestExpectation) { - recorder.fulfillOnCompletion(expectation) - } - - public func expectedValue() throws { - try recorder.value { (_, completion, remainingElements, consume) in - guard let completion = completion else { - consume(remainingElements.count) - return - } - if case let .failure(error) = completion { - throw error - } - } - } - - /// Returns an inverted publisher expectation which waits for a - /// publisher to complete successfully. - /// - /// When waiting for this expectation, an error is thrown if the - /// publisher fails with an error. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectDoesNotFinish() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// try wait(for: recorder.finished.inverted, timeout: 1) - /// } - public var inverted: Inverted { - return Inverted(base: self) - } - } -} diff --git a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Inverted.swift b/Tests/CombineTests/CombineExpectations/PublisherExpectations/Inverted.swift deleted file mode 100644 index c14e4ff..0000000 --- a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Inverted.swift +++ /dev/null @@ -1,29 +0,0 @@ -import XCTest - -extension PublisherExpectations { - /// A publisher expectation that fails if the base expectation is fulfilled. - /// - /// When waiting for this expectation, you receive the same result and - /// eventual error as the base expectation. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectDoesNotFinish() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// try wait(for: recorder.finished.inverted, timeout: 1) - /// } - public struct Inverted: PublisherExpectation { - let base: Base - - public func setup(_ expectation: XCTestExpectation) { - base.setup(expectation) - expectation.isInverted.toggle() - } - - public func expectedValue() throws -> Base.Output { - try base.expectedValue() - } - } -} diff --git a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Map.swift b/Tests/CombineTests/CombineExpectations/PublisherExpectations/Map.swift deleted file mode 100644 index 10e7df3..0000000 --- a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Map.swift +++ /dev/null @@ -1,27 +0,0 @@ -import XCTest - -extension PublisherExpectations { - /// A publisher expectation that transforms the value of a base expectation. - /// - /// This expectation has no public initializer. - public struct Map: PublisherExpectation { - let base: Base - let transform: (Base.Output) throws -> Output - - public func setup(_ expectation: XCTestExpectation) { - base.setup(expectation) - } - - public func expectedValue() throws -> Output { - try transform(base.expectedValue()) - } - } -} - -extension PublisherExpectation { - /// Returns a publisher expectation that transforms the value of the - /// base expectation. - func map(_ transform: @escaping (Output) throws -> T) -> PublisherExpectations.Map { - PublisherExpectations.Map(base: self, transform: transform) - } -} diff --git a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Next.swift b/Tests/CombineTests/CombineExpectations/PublisherExpectations/Next.swift deleted file mode 100644 index 36a5a5f..0000000 --- a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Next.swift +++ /dev/null @@ -1,63 +0,0 @@ -import XCTest - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension PublisherExpectations { - /// A publisher expectation which waits for the recorded publisher to emit - /// `count` elements, or to complete. - /// - /// When waiting for this expectation, a `RecordingError.notEnoughElements` - /// is thrown if the publisher does not publish `count` elements after last - /// waited expectation. The publisher error is thrown if the publisher fails - /// before publishing the next `count` elements. - /// - /// Otherwise, an array of exactly `count` elements is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayOfThreeElementsPublishesTwoThenOneElement() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// - /// var elements = try wait(for: recorder.next(2), timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar"]) - /// - /// elements = try wait(for: recorder.next(1), timeout: 1) - /// XCTAssertEqual(elements, ["baz"]) - /// } - public struct Next: PublisherExpectation { - let recorder: Recorder - let count: Int - - init(recorder: Recorder, count: Int) { - precondition(count >= 0, "Can't take a prefix of negative length") - self.recorder = recorder - self.count = count - } - - public func setup(_ expectation: XCTestExpectation) { - if count == 0 { - // Such an expectation is immediately fulfilled, by essence. - expectation.expectedFulfillmentCount = 1 - expectation.fulfill() - } else { - expectation.expectedFulfillmentCount = count - recorder.fulfillOnInput(expectation, includingConsumed: false) - } - } - - public func expectedValue() throws -> [Input] { - try recorder.value { (_, completion, remainingElements, consume) in - if remainingElements.count >= count { - consume(count) - return Array(remainingElements.prefix(count)) - } - if case let .failure(error) = completion { - throw error - } else { - throw RecordingError.notEnoughElements - } - } - } - } -} diff --git a/Tests/CombineTests/CombineExpectations/PublisherExpectations/NextOne.swift b/Tests/CombineTests/CombineExpectations/PublisherExpectations/NextOne.swift deleted file mode 100644 index 87796bf..0000000 --- a/Tests/CombineTests/CombineExpectations/PublisherExpectations/NextOne.swift +++ /dev/null @@ -1,105 +0,0 @@ -import XCTest - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension PublisherExpectations { - /// A publisher expectation which waits for the recorded publisher to emit - /// one element, or to complete. - /// - /// When waiting for this expectation, a `RecordingError.notEnoughElements` - /// is thrown if the publisher does not publish one element after last - /// waited expectation. The publisher error is thrown if the publisher fails - /// before publishing the next element. - /// - /// Otherwise, the next published element is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayOfTwoElementsPublishesElementsInOrder() throws { - /// let publisher = ["foo", "bar"].publisher - /// let recorder = publisher.record() - /// - /// var element = try wait(for: recorder.next(), timeout: 1) - /// XCTAssertEqual(element, "foo") - /// - /// element = try wait(for: recorder.next(), timeout: 1) - /// XCTAssertEqual(element, "bar") - /// } - public struct NextOne: PublisherExpectation { - let recorder: Recorder - - public func setup(_ expectation: XCTestExpectation) { - recorder.fulfillOnInput(expectation, includingConsumed: false) - } - - public func expectedValue() throws -> Input? { - try recorder.value { (_, completion, remainingElements, consume) in - if let next = remainingElements.first { - consume(1) - return next - } - if case let .failure(error) = completion { - throw error - } else { - throw RecordingError.notEnoughElements - } - } - } - - /// Returns an inverted publisher expectation which waits for the - /// recorded publisher to emit one element, or to complete. - /// - /// When waiting for this expectation, a RecordingError is thrown if the - /// publisher does not publish one element after last waited - /// expectation. The publisher error is thrown if the publisher fails - /// before publishing one element. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectDoesNotPublishAnyElement() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// try wait(for: recorder.next().inverted, timeout: 1) - /// } - public var inverted: NextOneInverted { - return NextOneInverted(recorder: recorder) - } - } - - /// An inverted publisher expectation which waits for the recorded publisher - /// to emit one element, or to complete. - /// - /// When waiting for this expectation, a RecordingError is thrown if the - /// publisher does not publish one element after last waited expectation. - /// The publisher error is thrown if the publisher fails before - /// publishing one element. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectDoesNotPublishAnyElement() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// try wait(for: recorder.next().inverted, timeout: 1) - /// } - public struct NextOneInverted: PublisherExpectation { - let recorder: Recorder - - public func setup(_ expectation: XCTestExpectation) { - expectation.isInverted = true - recorder.fulfillOnInput(expectation, includingConsumed: false) - } - - public func expectedValue() throws { - try recorder.value { (_, completion, remainingElements, consume) in - if remainingElements.isEmpty == false { - return - } - if case let .failure(error) = completion { - throw error - } - } - } - } -} diff --git a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Prefix.swift b/Tests/CombineTests/CombineExpectations/PublisherExpectations/Prefix.swift deleted file mode 100644 index 9a4dbe1..0000000 --- a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Prefix.swift +++ /dev/null @@ -1,95 +0,0 @@ -import XCTest - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension PublisherExpectations { - /// A publisher expectation which waits for the recorded publisher to emit - /// `maxLength` elements, or to complete. - /// - /// When waiting for this expectation, the publisher error is thrown if the - /// publisher fails before `maxLength` elements are published. - /// - /// Otherwise, an array of received elements is returned, containing at - /// most `maxLength` elements, or less if the publisher completes early. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayOfThreeElementsPublishesTwoFirstElementsWithoutError() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// let elements = try wait(for: recorder.prefix(2), timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar"]) - /// } - /// - /// This publisher expectation can be inverted: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectPublishesNoMoreThanSentValues() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// publisher.send("foo") - /// publisher.send("bar") - /// let elements = try wait(for: recorder.prefix(3).inverted, timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar"]) - /// } - public struct Prefix: PublisherExpectation { - let recorder: Recorder - let maxLength: Int - - init(recorder: Recorder, maxLength: Int) { - precondition(maxLength >= 0, "Can't take a prefix of negative length") - self.recorder = recorder - self.maxLength = maxLength - } - - public func setup(_ expectation: XCTestExpectation) { - if maxLength == 0 { - // Such an expectation is immediately fulfilled, by essence. - expectation.expectedFulfillmentCount = 1 - expectation.fulfill() - } else { - expectation.expectedFulfillmentCount = maxLength - recorder.fulfillOnInput(expectation, includingConsumed: true) - } - } - - public func expectedValue() throws -> [Input] { - try recorder.value { (elements, completion, remainingElements, consume) in - if elements.count >= maxLength { - let extraCount = max(maxLength + remainingElements.count - elements.count, 0) - consume(extraCount) - return Array(elements.prefix(maxLength)) - } - if case let .failure(error) = completion { - throw error - } - consume(remainingElements.count) - return elements - } - } - - /// Returns an inverted publisher expectation which waits for a - /// publisher to emit `maxLength` elements, or to complete. - /// - /// When waiting for this expectation, the publisher error is thrown - /// if the publisher fails before `maxLength` elements are published. - /// - /// Otherwise, an array of received elements is returned, containing at - /// most `maxLength` elements, or less if the publisher completes early. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectPublishesNoMoreThanSentValues() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// publisher.send("foo") - /// publisher.send("bar") - /// let elements = try wait(for: recorder.prefix(3).inverted, timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar"]) - /// } - public var inverted: Inverted { - return Inverted(base: self) - } - } -} diff --git a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Recording.swift b/Tests/CombineTests/CombineExpectations/PublisherExpectations/Recording.swift deleted file mode 100644 index 0d77a64..0000000 --- a/Tests/CombineTests/CombineExpectations/PublisherExpectations/Recording.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Combine -import XCTest - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension PublisherExpectations { - /// A publisher expectation which waits for the recorded publisher - /// to complete. - /// - /// When waiting for this expectation, a RecordingError.notCompleted is - /// thrown if the publisher does not complete on time. - /// - /// Otherwise, a [Record.Recording](https://developer.apple.com/documentation/combine/record/recording) - /// is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayPublisherRecording() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// let recording = try wait(for: recorder.recording, timeout: 1) - /// XCTAssertEqual(recording.output, ["foo", "bar", "baz"]) - /// if case let .failure(error) = recording.completion { - /// XCTFail("Unexpected error \(error)") - /// } - /// } - public struct Recording: PublisherExpectation { - let recorder: Recorder - - public func setup(_ expectation: XCTestExpectation) { - recorder.fulfillOnCompletion(expectation) - } - - public func expectedValue() throws -> Record.Recording { - try recorder.value { (elements, completion, remainingElements, consume) in - if let completion = completion { - consume(remainingElements.count) - return Record.Recording(output: elements, completion: completion) - } else { - throw RecordingError.notCompleted - } - } - } - } -} diff --git a/Tests/CombineTests/CombineExpectations/Recorder.swift b/Tests/CombineTests/CombineExpectations/Recorder.swift deleted file mode 100644 index 172932d..0000000 --- a/Tests/CombineTests/CombineExpectations/Recorder.swift +++ /dev/null @@ -1,540 +0,0 @@ -import Combine -import XCTest - -/// A Combine subscriber which records all events published by a publisher. -/// -/// You create a Recorder with the `Publisher.record()` method: -/// -/// let publisher = ["foo", "bar", "baz"].publisher -/// let recorder = publisher.record() -/// -/// You can build publisher expectations from the Recorder. For example: -/// -/// let elements = try wait(for: recorder.elements, timeout: 1) -/// XCTAssertEqual(elements, ["foo", "bar", "baz"]) -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -public class Recorder: Subscriber { - public typealias Input = Input - public typealias Failure = Failure - - private enum RecorderExpectation { - case onInput(XCTestExpectation, remainingCount: Int) - case onCompletion(XCTestExpectation) - - var expectation: XCTestExpectation { - switch self { - case let .onCompletion(expectation): - return expectation - case let .onInput(expectation, remainingCount: _): - return expectation - } - } - } - - private enum State { - case waitingForSubscription(RecorderExpectation?) - case subscribed(Subscription, RecorderExpectation?, [Input]) - case completed([Input], Subscribers.Completion) - - var elementsAndCompletion: (elements: [Input], completion: Subscribers.Completion?) { - switch self { - case .waitingForSubscription: - return (elements: [], completion: nil) - case let .subscribed(_, _, elements): - return (elements: elements, completion: nil) - case let .completed(elements, completion): - return (elements: elements, completion: completion) - } - } - } - - private let lock = NSLock() - private var state = State.waitingForSubscription(nil) - private var consumedCount = 0 - - /// The elements and completion recorded so far. - public var elementsAndCompletion: (elements: [Input], completion: Subscribers.Completion?) { - synchronized { - state.elementsAndCompletion - } - } - - /// Use Publisher.record() - fileprivate init() { } - - deinit { - if case let .subscribed(subscription, _, _) = state { - subscription.cancel() - } - } - - private func synchronized(_ execute: () throws -> T) rethrows -> T { - lock.lock() - defer { lock.unlock() } - return try execute() - } - - // MARK: - PublisherExpectation API - - func fulfillOnInput(_ expectation: XCTestExpectation, includingConsumed: Bool) { - synchronized { - switch state { - case let .waitingForSubscription(exp): - preconditionNotWaiting(for: exp) - let exp = RecorderExpectation.onInput(expectation, remainingCount: expectation.expectedFulfillmentCount) - state = .waitingForSubscription(exp) - - case let .subscribed(subscription, exp, elements): - preconditionNotWaiting(for: exp) - let fulfillmentCount: Int - if includingConsumed { - fulfillmentCount = min(expectation.expectedFulfillmentCount, elements.count) - } else { - fulfillmentCount = min(expectation.expectedFulfillmentCount, elements.count - consumedCount) - } - expectation.fulfill(count: fulfillmentCount) - - let remainingCount = expectation.expectedFulfillmentCount - fulfillmentCount - if remainingCount > 0 { - let exp = RecorderExpectation.onInput(expectation, remainingCount: remainingCount) - state = .subscribed(subscription, exp, elements) - } - - case .completed: - expectation.fulfill(count: expectation.expectedFulfillmentCount) - } - } - } - - func fulfillOnCompletion(_ expectation: XCTestExpectation) { - synchronized { - switch state { - case let .waitingForSubscription(exp): - preconditionNotWaiting(for: exp) - let exp = RecorderExpectation.onCompletion(expectation) - state = .waitingForSubscription(exp) - - case let .subscribed(subscription, exp, elements): - preconditionNotWaiting(for: exp) - let exp = RecorderExpectation.onCompletion(expectation) - state = .subscribed(subscription, exp, elements) - - case .completed: - expectation.fulfill() - } - } - } - - /// Returns a value based on the recorded state of the publisher. - /// - /// - parameter value: A function which returns the value, given the - /// recorded state of the publisher. - /// - parameter elements: All recorded elements. - /// - parameter completion: The eventual publisher completion. - /// - parameter remainingElements: The elements that were not consumed yet. - /// - parameter consume: A function which consumes elements. - /// - parameter count: The number of consumed elements. - /// - returns: The value - func value(_ value: ( - _ elements: [Input], - _ completion: Subscribers.Completion?, - _ remainingElements: ArraySlice, - _ consume: (_ count: Int) -> ()) throws -> T) - rethrows -> T - { - try synchronized { - let (elements, completion) = state.elementsAndCompletion - let remainingElements = elements[consumedCount...] - return try value(elements, completion, remainingElements, { count in - precondition(count >= 0) - precondition(count <= remainingElements.count) - consumedCount += count - }) - } - } - - // Recorder can fulfill a single expectation. When it is asked to fulfill - // another one, we have to check for programmer errors. - private func preconditionNotWaiting(for recorderExpectation: RecorderExpectation?) { - if let exp = recorderExpectation { - // We are already waiting for an expectation! Is it a programmer - // error? Recorder drops references to non-inverted expectations - // when they are fulfilled. But inverted expectations are not - // fulfilled, and thus not dropped. We can't quite know if an - // inverted expectations has expired yet, so just let it go. - precondition(exp.expectation.isInverted, "Already waiting for an expectation") - } - } - - // MARK: - Subscriber - - public func receive(subscription: Subscription) { - synchronized { - switch state { - case let .waitingForSubscription(exp): - state = .subscribed(subscription, exp, []) - default: - XCTFail("Publisher recorder is already subscribed") - } - } - subscription.request(.unlimited) - } - - public func receive(_ input: Input) -> Subscribers.Demand { - return synchronized { - switch state { - case let .subscribed(subscription, exp, elements): - var elements = elements - elements.append(input) - - if case let .onInput(expectation, remainingCount: remainingCount) = exp { - assert(remainingCount > 0) - expectation.fulfill() - if remainingCount > 1 { - let exp = RecorderExpectation.onInput(expectation, remainingCount: remainingCount - 1) - state = .subscribed(subscription, exp, elements) - } else { - state = .subscribed(subscription, nil, elements) - } - } else { - state = .subscribed(subscription, exp, elements) - } - - return .unlimited - - case .waitingForSubscription: - XCTFail("Publisher recorder got unexpected input before subscription: \(String(reflecting: input))") - return .none - - case .completed: - XCTFail("Publisher recorder got unexpected input after completion: \(String(reflecting: input))") - return .none - } - } - } - - public func receive(completion: Subscribers.Completion) { - synchronized { - switch state { - case let .subscribed(_, exp, elements): - if let exp = exp { - switch exp { - case let .onCompletion(expectation): - expectation.fulfill() - case let .onInput(expectation, remainingCount: remainingCount): - expectation.fulfill(count: remainingCount) - } - } - state = .completed(elements, completion) - - case .waitingForSubscription: - XCTFail("Publisher recorder got unexpected completion before subscription: \(String(describing: completion))") - - case .completed: - XCTFail("Publisher recorder got unexpected completion after completion: \(String(describing: completion))") - } - } - } -} - -// MARK: - Publisher Expectations -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension PublisherExpectations { - /// The type of the publisher expectation returned by Recorder.completion - public typealias Completion = Map, Subscribers.Completion> - - /// The type of the publisher expectation returned by Recorder.elements - public typealias Elements = Map, [Input]> - - /// The type of the publisher expectation returned by Recorder.last - public typealias Last = Map, Input?> - - /// The type of the publisher expectation returned by Recorder.single - public typealias Single = Map, Input> -} - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension Recorder { - /// Returns a publisher expectation which waits for the recorded publisher - /// to complete. - /// - /// When waiting for this expectation, a RecordingError.notCompleted is - /// thrown if the publisher does not complete on time. - /// - /// Otherwise, a [Subscribers.Completion](https://developer.apple.com/documentation/combine/subscribers/completion) - /// is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayPublisherCompletesWithSuccess() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// let completion = try wait(for: recorder.completion, timeout: 1) - /// if case let .failure(error) = completion { - /// XCTFail("Unexpected error \(error)") - /// } - /// } - public var completion: PublisherExpectations.Completion { - recording.map { $0.completion } - } - - /// Returns a publisher expectation which waits for the recorded publisher - /// to complete. - /// - /// When waiting for this expectation, a RecordingError.notCompleted is - /// thrown if the publisher does not complete on time, and the publisher - /// error is thrown if the publisher fails. - /// - /// Otherwise, an array of published elements is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayPublisherPublishesArrayElements() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// let elements = try wait(for: recorder.elements, timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar", "baz"]) - /// } - public var elements: PublisherExpectations.Elements { - recording.map { recording in - if case let .failure(error) = recording.completion { - throw error - } - return recording.output - } - } - - /// Returns a publisher expectation which waits for the recorded publisher - /// to complete. - /// - /// When waiting for this expectation, the publisher error is thrown if the - /// publisher fails. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayPublisherFinishesWithoutError() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// try wait(for: recorder.finished, timeout: 1) - /// } - /// - /// This publisher expectation can be inverted: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectDoesNotFinish() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// try wait(for: recorder.finished.inverted, timeout: 1) - /// } - public var finished: PublisherExpectations.Finished { - PublisherExpectations.Finished(recorder: self) - } - - /// Returns a publisher expectation which waits for the recorded publisher - /// to complete. - /// - /// When waiting for this expectation, a RecordingError.notCompleted is - /// thrown if the publisher does not complete on time, and the publisher - /// error is thrown if the publisher fails. - /// - /// Otherwise, the last published element is returned, or nil if the publisher - /// completes before it publishes any element. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayPublisherPublishesLastElementLast() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// if let element = try wait(for: recorder.last, timeout: 1) { - /// XCTAssertEqual(element, "baz") - /// } else { - /// XCTFail("Expected one element") - /// } - /// } - public var last: PublisherExpectations.Last { - elements.map { $0.last } - } - - /// Returns a publisher expectation which waits for the recorded publisher - /// to emit one element, or to complete. - /// - /// When waiting for this expectation, a `RecordingError.notEnoughElements` - /// is thrown if the publisher does not publish one element after last - /// waited expectation. The publisher error is thrown if the publisher fails - /// before publishing the next element. - /// - /// Otherwise, the next published element is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayOfTwoElementsPublishesElementsInOrder() throws { - /// let publisher = ["foo", "bar"].publisher - /// let recorder = publisher.record() - /// - /// var element = try wait(for: recorder.next(), timeout: 1) - /// XCTAssertEqual(element, "foo") - /// - /// element = try wait(for: recorder.next(), timeout: 1) - /// XCTAssertEqual(element, "bar") - /// } - public func next() -> PublisherExpectations.NextOne { - PublisherExpectations.NextOne(recorder: self) - } - - /// Returns a publisher expectation which waits for the recorded publisher - /// to emit `count` elements, or to complete. - /// - /// When waiting for this expectation, a `RecordingError.notEnoughElements` - /// is thrown if the publisher does not publish `count` elements after last - /// waited expectation. The publisher error is thrown if the publisher fails - /// before publishing the next `count` elements. - /// - /// Otherwise, an array of exactly `count` elements is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayOfThreeElementsPublishesTwoThenOneElement() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// - /// var elements = try wait(for: recorder.next(2), timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar"]) - /// - /// elements = try wait(for: recorder.next(1), timeout: 1) - /// XCTAssertEqual(elements, ["baz"]) - /// } - /// - /// - parameter count: The number of elements. - public func next(_ count: Int) -> PublisherExpectations.Next { - PublisherExpectations.Next(recorder: self, count: count) - } - - /// Returns a publisher expectation which waits for the recorded publisher - /// to emit `maxLength` elements, or to complete. - /// - /// When waiting for this expectation, the publisher error is thrown if the - /// publisher fails before `maxLength` elements are published. - /// - /// Otherwise, an array of received elements is returned, containing at - /// most `maxLength` elements, or less if the publisher completes early. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayOfThreeElementsPublishesTwoFirstElementsWithoutError() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// let elements = try wait(for: recorder.prefix(2), timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar"]) - /// } - /// - /// This publisher expectation can be inverted: - /// - /// // SUCCESS: no timeout, no error - /// func testPassthroughSubjectPublishesNoMoreThanSentValues() throws { - /// let publisher = PassthroughSubject() - /// let recorder = publisher.record() - /// publisher.send("foo") - /// publisher.send("bar") - /// let elements = try wait(for: recorder.prefix(3).inverted, timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar"]) - /// } - /// - /// - parameter maxLength: The maximum number of elements. - public func prefix(_ maxLength: Int) -> PublisherExpectations.Prefix { - PublisherExpectations.Prefix(recorder: self, maxLength: maxLength) - } - - /// Returns a publisher expectation which waits for the recorded publisher - /// to complete. - /// - /// When waiting for this expectation, a RecordingError.notCompleted is - /// thrown if the publisher does not complete on time. - /// - /// Otherwise, a [Record.Recording](https://developer.apple.com/documentation/combine/record/recording) - /// is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testArrayPublisherRecording() throws { - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// let recording = try wait(for: recorder.recording, timeout: 1) - /// XCTAssertEqual(recording.output, ["foo", "bar", "baz"]) - /// if case let .failure(error) = recording.completion { - /// XCTFail("Unexpected error \(error)") - /// } - /// } - public var recording: PublisherExpectations.Recording { - PublisherExpectations.Recording(recorder: self) - } - - /// Returns a publisher expectation which waits for the recorded publisher - /// to complete. - /// - /// When waiting for this expectation, a RecordingError is thrown if the - /// publisher does not complete on time, or does not publish exactly one - /// element before it completes. The publisher error is thrown if the - /// publisher fails. - /// - /// Otherwise, the single published element is returned. - /// - /// For example: - /// - /// // SUCCESS: no timeout, no error - /// func testJustPublishesExactlyOneElement() throws { - /// let publisher = Just("foo") - /// let recorder = publisher.record() - /// let element = try wait(for: recorder.single, timeout: 1) - /// XCTAssertEqual(element, "foo") - /// } - public var single: PublisherExpectations.Single { - elements.map { elements in - guard let element = elements.first else { - throw RecordingError.notEnoughElements - } - if elements.count > 1 { - throw RecordingError.tooManyElements - } - return element - } - } -} - -// MARK: - Publisher + Recorder -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension Publisher { - /// Returns a subscribed Recorder. - /// - /// For example: - /// - /// let publisher = ["foo", "bar", "baz"].publisher - /// let recorder = publisher.record() - /// - /// You can build publisher expectations from the Recorder. For example: - /// - /// let elements = try wait(for: recorder.elements, timeout: 1) - /// XCTAssertEqual(elements, ["foo", "bar", "baz"]) - public func record() -> Recorder { - let recorder = Recorder() - subscribe(recorder) - return recorder - } -} - -// MARK: - Convenience -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -extension XCTestExpectation { - fileprivate func fulfill(count: Int) { - for _ in 0.. { - func finishEventStream() { - self.eventSubject.send(completion: .finished) - } -} - @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) final class FeedbackTests: XCTestCase { + private var subscriptions = [AnyCancellable]() func test_effect_observes_on_current_executer_when_nilExecuter_is_passed_to_initializer() throws { + let exp = expectation(description: "Effects") + var effectIsCalled = false var receivedExecuterName = "" let expectedExecuterName = "FEEDBACK_QUEUE_\(UUID().uuidString)" @@ -41,8 +37,12 @@ final class FeedbackTests: XCTestCase { .eraseToAnyPublisher() // When: executing the feedback - let recorder = sut.effect(inputStream).record() - _ = try wait(for: recorder.completion, timeout: 5) + sut + .effect(inputStream) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in exp.fulfill() }) + .store(in: &self.subscriptions) + + waitForExpectations(timeout: 0.5) // Then: the effect is called // Then: the effect happens on the dedicated Executer specified in the inputStream, since no Executer has been given @@ -52,6 +52,8 @@ final class FeedbackTests: XCTestCase { } func test_effect_observes_on_an_executer_when_one_is_passed_to_initializer() throws { + let exp = expectation(description: "Effects") + var effectIsCalled = false var receivedExecuterName = "" let expectedExecuterName = "FEEDBACK_QUEUE_\(UUID().uuidString)" @@ -71,8 +73,11 @@ final class FeedbackTests: XCTestCase { .eraseToAnyPublisher() // When: executing the feedback - let recorder = sut.effect(inputStream).record() - _ = try wait(for: recorder.completion, timeout: 5) + sut.effect(inputStream) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in exp.fulfill() }) + .store(in: &self.subscriptions) + + waitForExpectations(timeout: 0.5) // Then: the effect is called // Then: the effect happens on the dedicated Executer given in the Feedback initializer, not on the one defined @@ -82,6 +87,9 @@ final class FeedbackTests: XCTestCase { } func test_init_produces_a_non_cancellable_stream_when_called_with_continueOnNewEvent_strategy() throws { + let exp = expectation(description: "ContinueOnEvent") + var receivedElements = [String]() + // Given: an effect that performs a long operation when given 1 as an input, and an immediate operation otherwise func makeLongOperationEffect(outputing: Int) -> AnyPublisher { return Future { (observer) in @@ -107,14 +115,20 @@ final class FeedbackTests: XCTestCase { let sut = Feedback(effect: effect, applying: .continueOnNewState).effect // When: feeding this effect with 2 events: 1 and 2 - let recorder = sut([1, 2].publisher.eraseToAnyPublisher()).record() - let receivedElements = try wait(for: recorder.elements, timeout: 5) + sut([1, 2].publisher.eraseToAnyPublisher()) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { element in receivedElements.append(element) }) + .store(in: &self.subscriptions) + + waitForExpectations(timeout: 5) // Then: the stream waits for the long operation to end before completing XCTAssertEqual(receivedElements, ["2", "1"]) } func test_init_produces_a_cancellable_stream_when_called_with_cancelOnNewEvent_strategy() throws { + let exp = expectation(description: "ContinueOnEvent") + var receivedElements = [String]() + // Given: an effect that performs a long operation when given 1 as an input, and an immediate operation otherwise func makeLongOperationEffect(outputing: Int) -> AnyPublisher { return Future { (observer) in @@ -140,8 +154,11 @@ final class FeedbackTests: XCTestCase { let sut = Feedback(effect: effect, applying: .cancelOnNewState).effect // When: feeding this stream with 2 events: 1 and 2 - let recorder = sut([1, 2].publisher.eraseToAnyPublisher()).record() - let receivedElements = try wait(for: recorder.elements, timeout: 5) + sut([1, 2].publisher.eraseToAnyPublisher()) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { element in receivedElements.append(element) }) + .store(in: &self.subscriptions) + + waitForExpectations(timeout: 5) // Then: the stream does not wait for the long operation to end before completing, the first operation is cancelled in favor // of the immediate one @@ -149,6 +166,8 @@ final class FeedbackTests: XCTestCase { } func test_directEffect_is_used() throws { + let exp = expectation(description: "Effects") + var effectIsCalled = false // Given: a feedback from a directEffect @@ -160,14 +179,18 @@ final class FeedbackTests: XCTestCase { // When: executing the feedback let inputStream = Just(1701).eraseToAnyPublisher() - let recorder = sut.effect(inputStream).record() - _ = try wait(for: recorder.completion, timeout: 5) + sut.effect(inputStream) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { _ in }) + .store(in: &self.subscriptions) + + waitForExpectations(timeout: 0.5) // Then: the directEffect is called XCTAssertTrue(effectIsCalled) } func test_effects_are_used() throws { + let exp = expectation(description: "Effects") var effectAIsCalled = false var effectBIsCalled = false @@ -185,8 +208,12 @@ final class FeedbackTests: XCTestCase { // When: executing the feedback let inputStream = Just(1701).eraseToAnyPublisher() - let recorder = sut.effect(inputStream).record() - _ = try wait(for: recorder.completion, timeout: 5) + sut + .effect(inputStream) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { _ in }) + .store(in: &self.subscriptions) + + waitForExpectations(timeout: 0.5) // Then: the effects are called XCTAssertTrue(effectAIsCalled) @@ -194,11 +221,14 @@ final class FeedbackTests: XCTestCase { } func testFeedback_call_gearSideEffect_and_does_only_trigger_a_feedbackEvent_when_attachment_return_not_nil() throws { - let spyGear = SpyGear() + let exp = expectation(description: "Gear") + + let gear = Gear() var numberOfCallsGearSideEffect = 0 + var receivedElements = [String]() // Given: a feedback attached to a Gear and triggering en event only of the gear event is 1 - let sut = Feedback(attachedTo: spyGear, propagating: { gearEvent -> String? in + let sut = Feedback(attachedTo: gear, propagating: { gearEvent -> String? in numberOfCallsGearSideEffect += 1 if gearEvent == 1 { return "event" @@ -209,13 +239,17 @@ final class FeedbackTests: XCTestCase { // When: executing the feedback let inputStream = Just(1701).eraseToAnyPublisher() - let recorder = sut.effect(inputStream).record() + sut + .effect(inputStream) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { element in receivedElements.append(element) }) + .store(in: &self.subscriptions) // When: sending 0 and then 1 as gear event - spyGear.eventSubject.send(0) - spyGear.eventSubject.send(1) - spyGear.finishEventStream() - let receivedElements = try wait(for: recorder.elements, timeout: 5) + gear.eventSubject.send(0) + gear.eventSubject.send(1) + gear.eventSubject.send(completion: .finished) + + waitForExpectations(timeout: 0.5) // Then: the gear dedicated side effect is called twice // Then: the only event triggered by the feedback is the one when attachment is not nil diff --git a/Tests/CombineTests/ReducerTests.swift b/Tests/CombineTests/ReducerTests.swift deleted file mode 100644 index ca68203..0000000 --- a/Tests/CombineTests/ReducerTests.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// ReducerTests.swift -// -// -// Created by Thibault Wittemberg on 2019-12-31. -// - -import Combine -import SpinCombine -import SpinCommon -import XCTest - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -final class ReducerTests: XCTestCase { - - private var disposeBag = [AnyCancellable]() - - func test_reducer_is_performed_on_default_executer_when_no_executer_is_specified() throws { - // Given: an event stream switching on a specified Executer after being executed - // and a Reducer applied after this event stream - let exp = expectation(description: "default executer for reducer") - var reduceIsCalled = false - let expectedExecuterName = "com.apple.main-thread" - var receivedExecuterName = "" - - let inputStreamSchedulerQueueLabel = "INPUT_STREAM_QUEUE_\(UUID().uuidString)" - let inputStreamScheduler = DispatchQueue(label: inputStreamSchedulerQueueLabel, - qos: .userInitiated) - - let eventStream = Just("").receive(on: inputStreamScheduler).eraseToAnyPublisher() - - let reducerFunction = { (state: String, action: String) -> String in - reduceIsCalled = true - receivedExecuterName = DispatchQueue.currentLabel - exp.fulfill() - return "" - } - - // When: reducing without specifying an Executer for the reduce operation - let sut = ScheduledReducer(reducerFunction) - let scheduledReducer = sut.scheduledReducer(with: "initialState") - - scheduledReducer(eventStream) - .output(in: (0...1)) - .subscribe() - .disposed(by: &self.disposeBag) - - waitForExpectations(timeout: 5) - - // Then: the reduce is performed - // Then: the reduce is performed on the default executer, ie the main queue for Reducer - XCTAssertTrue(reduceIsCalled) - XCTAssertEqual(receivedExecuterName, expectedExecuterName) - } - - func test_reducer_is_performed_on_dedicated_executer_when_executer_is_specified() throws { - // Given: a effect switching on a specified Executer after being executed - let exp = expectation(description: "default executer for reducer") - var reduceIsCalled = false - let expectedExecuterName = "REDUCER_QUEUE_\(UUID().uuidString)" - var receivedExecuterName = "" - - let inputStreamSchedulerQueueLabel = "INPUT_STREAM_QUEUE_\(UUID().uuidString)" - let inputStreamScheduler = DispatchQueue(label: inputStreamSchedulerQueueLabel, - qos: .userInitiated) - let reducerScheduler = DispatchQueue(label: expectedExecuterName, - qos: .userInitiated).eraseToAnyScheduler() - - let eventStream = Just("").receive(on: inputStreamScheduler).eraseToAnyPublisher() - - let reducerFunction = { (state: String, action: String) -> String in - reduceIsCalled = true - receivedExecuterName = DispatchQueue.currentLabel - exp.fulfill() - return "" - } - - // When: reducing with specifying an Executer for the reduce operation - let sut = ScheduledReducer(reducerFunction, on: reducerScheduler) - let scheduledReducer = sut.scheduledReducer(with: "initialState") - - scheduledReducer(eventStream) - .output(in: (0...1)) - .subscribe() - .disposed(by: &self.disposeBag) - - waitForExpectations(timeout: 5) - - // Then: the reduce is performed - // Then: the reduce is performed on the current executer, ie the one set by the effect - XCTAssertTrue(reduceIsCalled) - XCTAssertEqual(receivedExecuterName, expectedExecuterName) - } -} diff --git a/Tests/CombineTests/SpinIntegrationTests.swift b/Tests/CombineTests/SpinIntegrationTests.swift index e9bfd6d..92097c5 100644 --- a/Tests/CombineTests/SpinIntegrationTests.swift +++ b/Tests/CombineTests/SpinIntegrationTests.swift @@ -20,10 +20,13 @@ final class SpinIntegrationTests: XCTestCase { private var subscriptions = [AnyCancellable]() func test_multiple_feedbacks_produces_incremental_states_while_executed_on_default_executer() throws { + let exp = expectation(description: "Integration") + var receivedStatesInEffects = [String]() // Given: an initial state, effects and a reducer var counterA = 0 let effectA = { (state: String) -> AnyPublisher in + guard state == "initialState" || state.dropLast().last == "c" else { return Empty().eraseToAnyPublisher() } counterA += 1 let counter = counterA return Just(.append("_a\(counter)")).eraseToAnyPublisher() @@ -31,6 +34,7 @@ final class SpinIntegrationTests: XCTestCase { var counterB = 0 let effectB = { (state: String) -> AnyPublisher in + guard state.dropLast().last == "a" else { return Empty().eraseToAnyPublisher() } counterB += 1 let counter = counterB return Just(.append("_b\(counter)")).eraseToAnyPublisher() @@ -38,11 +42,17 @@ final class SpinIntegrationTests: XCTestCase { var counterC = 0 let effectC = { (state: String) -> AnyPublisher in + guard state.dropLast().last == "b" else { return Empty().eraseToAnyPublisher() } counterC += 1 let counter = counterC return Just(.append("_c\(counter)")).eraseToAnyPublisher() } + let spyEffect = { (state: String) -> AnyPublisher in + receivedStatesInEffects.append(state) + return Empty().eraseToAnyPublisher() + } + let reducerFunction = { (state: String, action: StringAction) -> String in switch action { case .append(let suffix): @@ -56,29 +66,35 @@ final class SpinIntegrationTests: XCTestCase { .feedback(Feedback(effect: effectA)) .feedback(Feedback(effect: effectB)) .feedback(Feedback(effect: effectC)) + .feedback(Feedback(effect: spyEffect)) .reducer(Reducer(reducerFunction)) - let recorder = AnyPublisher.stream(from: spin) - .output(in: (0...6)) - .record() + AnyPublisher.stream(from: spin) + .output(in: 0...5) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { _ in }) + .store(in: &self.subscriptions) - let receivedElements = try wait(for: recorder.elements, timeout: 5) + waitForExpectations(timeout: 1) // Then: the states is constructed incrementally - XCTAssertEqual(receivedElements, ["initialState", - "initialState_a1", - "initialState_a1_b1", - "initialState_a1_b1_c1", - "initialState_a1_b1_c1_a2", - "initialState_a1_b1_c1_a2_b2", - "initialState_a1_b1_c1_a2_b2_c2"]) + XCTAssertEqual(receivedStatesInEffects, ["initialState", + "initialState_a1", + "initialState_a1_b1", + "initialState_a1_b1_c1", + "initialState_a1_b1_c1_a2", + "initialState_a1_b1_c1_a2_b2", + "initialState_a1_b1_c1_a2_b2_c2"]) } func test_multiple_feedbacks_produces_incremental_states_while_executed_on_default_executer_using_declarative_syntax() throws { + let exp = expectation(description: "Integration") + + var receivedStatesInEffects = [String]() // Given: an initial state, effect and a reducer var counterA = 0 let effectA = { (state: String) -> AnyPublisher in + guard state == "initialState" || state.dropLast().last == "c" else { return Empty().eraseToAnyPublisher() } counterA += 1 let counter = counterA return Just(.append("_a\(counter)")).eraseToAnyPublisher() @@ -86,6 +102,7 @@ final class SpinIntegrationTests: XCTestCase { var counterB = 0 let effectB = { (state: String) -> AnyPublisher in + guard state.dropLast().last == "a" else { return Empty().eraseToAnyPublisher() } counterB += 1 let counter = counterB return Just(.append("_b\(counter)")).eraseToAnyPublisher() @@ -93,11 +110,18 @@ final class SpinIntegrationTests: XCTestCase { var counterC = 0 let effectC = { (state: String) -> AnyPublisher in + print("C" + state) + guard state.dropLast().last == "b" else { return Empty().eraseToAnyPublisher() } counterC += 1 let counter = counterC return Just(.append("_c\(counter)")).eraseToAnyPublisher() } + let spyEffect = { (state: String) -> AnyPublisher in + receivedStatesInEffects.append(state) + return Empty().eraseToAnyPublisher() + } + let reducerFunction = { (state: String, action: StringAction) -> String in switch action { case .append(let suffix): @@ -106,27 +130,29 @@ final class SpinIntegrationTests: XCTestCase { } let spin = Spin(initialState: "initialState") { - Feedback(effect: effectA).execute(on: DispatchQueue.main.eraseToAnyScheduler()) - Feedback(effect: effectB).execute(on: DispatchQueue.main.eraseToAnyScheduler()) - Feedback(effect: effectC).execute(on: DispatchQueue.main.eraseToAnyScheduler()) + Feedback(effect: effectA) + Feedback(effect: effectB) + Feedback(effect: effectC) + Feedback(effect: spyEffect) Reducer(reducerFunction) } // When: spinning the feedbacks and the reducer on the default executer - let recorder = AnyPublisher.stream(from: spin) - .output(in: (0...6)) - .record() + AnyPublisher.stream(from: spin) + .output(in: 0...5) + .sink(receiveCompletion: { _ in exp.fulfill() }, receiveValue: { _ in }) + .store(in: &self.subscriptions) - let receivedElements = try wait(for: recorder.elements, timeout: 5) + waitForExpectations(timeout: 1) // Then: the states is constructed incrementally - XCTAssertEqual(receivedElements, ["initialState", - "initialState_a1", - "initialState_a1_b1", - "initialState_a1_b1_c1", - "initialState_a1_b1_c1_a2", - "initialState_a1_b1_c1_a2_b2", - "initialState_a1_b1_c1_a2_b2_c2"]) + XCTAssertEqual(receivedStatesInEffects, ["initialState", + "initialState_a1", + "initialState_a1_b1", + "initialState_a1_b1_c1", + "initialState_a1_b1_c1_a2", + "initialState_a1_b1_c1_a2_b2", + "initialState_a1_b1_c1_a2_b2_c2"]) } } @@ -179,35 +205,46 @@ extension SpinIntegrationTests { func testAttach_trigger_checkAuthorizationSpin_when_fetchFeatureSpin_trigger_gear() { let exp = expectation(description: "Gear") - var receivedStates = [Any]() + var receivedCheckAuthorization = [CheckAuthorizationSpinState]() + var receivedFeatureStates = [FetchFeatureSpinState]() // Given: 2 independents spins and a shared gear let gear = Gear() let fetchFeatureSpin = self.makeFetchFeatureSpin(attachedTo: gear) let checkAuthorizationSpin = self.makeCheckAuthorizationSpin(attachedTo: gear) - // When: executing the 2 spins - AnyPublisher.stream(from: checkAuthorizationSpin) - .sink(receiveCompletion: { _ in }) { state in - receivedStates.append(state) + let spyEffectFeatureSpin = { (state: FetchFeatureSpinState) -> AnyPublisher in + receivedFeatureStates.append(state) + return Empty().eraseToAnyPublisher() + } + fetchFeatureSpin.effects.append(Feedback(effect: spyEffectFeatureSpin).effect) + + let spyEffectCheckAuthorizationSpin = { (state: CheckAuthorizationSpinState) -> AnyPublisher in + receivedCheckAuthorization.append(state) if state == .userHasBeenRevoked { exp.fulfill() } - }.store(in: &self.subscriptions) + return Empty().eraseToAnyPublisher() + } + checkAuthorizationSpin.effects.append(Feedback(effect: spyEffectCheckAuthorizationSpin).effect) + + // When: executing the 2 spins + AnyPublisher.stream(from: checkAuthorizationSpin) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in } ) + .store(in: &self.subscriptions) AnyPublisher.stream(from:fetchFeatureSpin) - .sink(receiveCompletion: { _ in }) { state in - receivedStates.append(state) - }.store(in: &self.subscriptions) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in } ) + .store(in: &self.subscriptions) waitForExpectations(timeout: 0.5) // Then: the stream of states produced by the spins are the expected one thanks to the propagation of the gear - XCTAssertEqual(receivedStates[0] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.initial) - XCTAssertEqual(receivedStates[1] as? FetchFeatureSpinState, FetchFeatureSpinState.initial) - XCTAssertEqual(receivedStates[2] as? FetchFeatureSpinState, FetchFeatureSpinState.unauthorized) - XCTAssertEqual(receivedStates[3] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.authorizationShouldBeChecked) - XCTAssertEqual(receivedStates[4] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.userHasBeenRevoked) + XCTAssertEqual(receivedCheckAuthorization[0], .initial) + XCTAssertEqual(receivedFeatureStates[0], .initial) + XCTAssertEqual(receivedFeatureStates[1], .unauthorized) + XCTAssertEqual(receivedCheckAuthorization[1], .authorizationShouldBeChecked) + XCTAssertEqual(receivedCheckAuthorization[2], .userHasBeenRevoked) } } diff --git a/Tests/CombineTests/SwiftUISpinTests.swift b/Tests/CombineTests/SwiftUISpinTests.swift index a6b2a46..602bf40 100644 --- a/Tests/CombineTests/SwiftUISpinTests.swift +++ b/Tests/CombineTests/SwiftUISpinTests.swift @@ -1,261 +1,263 @@ // // SwiftUISpinTests.swift -// +// // // Created by Thibault Wittemberg on 2020-03-03. // import Combine import SpinCombine +import SpinCommon import XCTest -fileprivate class SpyContainer { - - var isRenderCalled = false - var receivedState = "" - - func render(state: String) { - self.receivedState = state - self.isRenderCalled = true - } -} - @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) final class SwiftUISpinTests: XCTestCase { - - private var disposeBag = [AnyCancellable]() - + private var subscriptions = [AnyCancellable]() + func test_SwiftUISpin_sets_the_published_state_with_the_initialState_of_the_inner_spin() { // Given: a Spin with an initialState let initialState = "initialState" - + let feedback = Feedback(effect: { states in states.map { state -> String in return "event" }.eraseToAnyPublisher() }) - + let reducer = Reducer({ state, _ in return "newState" }) - + let spin = Spin(initialState: initialState) { feedback reducer } - + // When: building a SwiftUISpin with the Spin let sut = SwiftUISpin(spin: spin) - + // Then: the SwiftUISpin sets the published state with the initialState XCTAssertEqual(sut.state, initialState) } - + func test_SwiftUISpin_initialization_adds_a_ui_effect_to_the_inner_spin() { // Given: a Spin with an initialState and 1 effect let initialState = "initialState" - + let feedback = Feedback(effect: { states in states.map { state -> String in return "event" }.eraseToAnyPublisher() }) - + let reducer = Reducer({ state, _ in return "newState" }) - + let spin = Spin(initialState: initialState) { feedback reducer } - + // When: building a SwiftUISpin with the Spin let sut = SwiftUISpin(spin: spin) - + // Then: the SwiftUISpin adds 1 new ui effect XCTAssertEqual(sut.effects.count, 2) } - + func test_SwiftUISpin_send_events_in_the_reducer_when_emit_is_called() throws { // Given: a Spin let exp = expectation(description: "emit") let initialState = "initialState" var receivedEvent = "" - + let feedback = Feedback(effect: { states in return Empty().eraseToAnyPublisher() }) - + let reducer = Reducer({ state, event in receivedEvent = event exp.fulfill() return "newState" }) - + let spin = Spin(initialState: initialState) { feedback reducer } - + // When: building a SwiftUISpin with the Spin and running the SwiftUISpin and emitting an event let sut = SwiftUISpin(spin: spin) AnyPublisher .stream(from: sut) - .output(in: (0...1)) + .output(in: (0...0)) .subscribe() - .disposed(by: &self.disposeBag) + .store(in: &self.subscriptions) sut.emit("newEvent") - + waitForExpectations(timeout: 5) - + // Then: the event is received in the reducer XCTAssertEqual(receivedEvent, "newEvent") } - + func test_binding_make_the_SwiftUISpin_emit_an_event_when_the_binding_is_mutated() { // Given: a Spin let exp = expectation(description: "binding") let initialState = "initialState" var receivedEvent = "" - + let feedback = Feedback(effect: { states in return Empty().eraseToAnyPublisher() }) - + let reducer = Reducer({ state, event in receivedEvent = event exp.fulfill() return "newState" }) - + let spin = Spin(initialState: initialState) { feedback reducer } - + // When: building a SwiftUISpin with the Spin and running the SwiftUISpin and getting a binding // and then mutating the wrapped value of the binding let sut = SwiftUISpin(spin: spin) AnyPublisher .stream(from: sut) - .output(in: (0...1)) + .output(in: (0...0)) .subscribe() - .disposed(by: &self.disposeBag) + .store(in: &self.subscriptions) let binding = sut.binding(for: \.count, event: { "\($0)" }) binding.wrappedValue = 16 - + waitForExpectations(timeout: 5) - + // Then: the event from the binding mutation is received in the reducer XCTAssertEqual(receivedEvent, "16") } - + func test_binding_make_the_SwiftUISpin_emit_directly_an_event_when_the_binding_is_mutated() { // Given: a Spin let exp = expectation(description: "binding") let initialState = "initialState" var receivedEvent = "" - + let feedback = Feedback(effect: { states in return Empty().eraseToAnyPublisher() }) - + let reducer = Reducer({ state, event in receivedEvent = event exp.fulfill() return "newState" }) - + let spin = Spin(initialState: initialState) { feedback reducer } - + // When: building a SwiftUISpin with the Spin and running the SwiftUISpin and getting a binding // and then mutating the wrapped value of the binding let sut = SwiftUISpin(spin: spin) AnyPublisher .stream(from: sut) - .output(in: (0...1)) + .output(in: (0...0)) .subscribe() - .disposed(by: &self.disposeBag) + .store(in: &self.subscriptions) let binding = sut.binding(for: \.count, event: "newEvent") binding.wrappedValue = 16 - + waitForExpectations(timeout: 5) - + // Then: the event from the binding mutation is received in the reducer XCTAssertEqual(receivedEvent, "newEvent") } - + func test_SwiftUISpin_runs_the_stream_when_start_is_called() { // Given: a Spin let exp = expectation(description: "spin") let initialState = "initialState" var receivedState = "" - + let feedback = Feedback(effect: { (state: String) in receivedState = state exp.fulfill() return Empty().eraseToAnyPublisher() }) - + let reducer = Reducer({ state, event in return "newState" }) - + let spin = Spin(initialState: initialState) { feedback reducer } - + // When: building a SwiftUISpin with the Spin and running the SwiftUISpin let sut = SwiftUISpin(spin: spin) AnyPublisher .start(spin: sut) - .disposed(by: &self.disposeBag) + .store(in: &self.subscriptions) waitForExpectations(timeout: 5) - + // Then: the reactive stream is launched and the initialState is received in the effect XCTAssertEqual(receivedState, initialState) } func test_SwiftUISpin_mutates_the_inner_state() throws { + let exp = expectation(description: "SwiftUISpin") + // we are expecting 2 fulfillment since the ui state will receive initialState and then newState + exp.expectedFulfillmentCount = 2 + // Given: a Spin with an initialState and 1 effect + let expectedExecutionQueue = "com.apple.main-thread" + var receivedExecutionQueue = "" + let expectedState = "newState" let initialState = "initialState" - - let feedback = Feedback(effect: { states in - states.map { state -> String in - return "event" - }.eraseToAnyPublisher() + + let feedback = Feedback(effect: { (state: String) -> AnyPublisher in + guard state == "initialState" else { return Empty().eraseToAnyPublisher() } + return Just("event").eraseToAnyPublisher() }) - + let reducer = Reducer({ state, _ in return "newState" }) - + let spin = Spin(initialState: initialState) { feedback reducer } - - // When: building a UISpin with the Spin + + // When: building a SwiftUISpin with the Spin // When: starting the spin - let sut = UISpin(spin: spin) - - let recorder = AnyPublisher + let sut = SwiftUISpin(spin: spin, extraRenderStateFunction: { + receivedExecutionQueue = DispatchQueue.currentLabel + }) + + AnyPublisher .stream(from: sut) - .output(in: (0...2)) - .record() - - _ = try wait(for: recorder.completion, timeout: 5) + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + .store(in: &self.subscriptions) - // Then: the state is mutated - XCTAssertEqual(sut.state, "newState") + sut.$state.sink { _ in + exp.fulfill() + }.store(in: &self.subscriptions) + + waitForExpectations(timeout: 0.5) + + // Then: the state is mutated on the main thread + XCTAssertEqual(sut.state, expectedState) + XCTAssertEqual(receivedExecutionQueue, expectedExecutionQueue) } } diff --git a/Tests/CombineTests/UISpinTests.swift b/Tests/CombineTests/UISpinTests.swift index 5e69e04..6f6df7f 100644 --- a/Tests/CombineTests/UISpinTests.swift +++ b/Tests/CombineTests/UISpinTests.swift @@ -6,24 +6,29 @@ // import Combine -import SpinCombine +@testable import SpinCombine +import SpinCommon import XCTest fileprivate class SpyRenderer { - - var isRenderCalled = false var receivedState = "" + var executionQueue = "" + let expectation: XCTestExpectation + + init(expectation: XCTestExpectation) { + self.expectation = expectation + } func render(state: String) { + self.executionQueue = DispatchQueue.currentLabel self.receivedState = state - self.isRenderCalled = true + self.expectation.fulfill() } } @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) final class UISpinTests: XCTestCase { - - private var disposeBag = [AnyCancellable]() + private var subscriptions = [AnyCancellable]() func test_UISpin_sets_the_initial_state_with_the_initialState_of_the_inner_spin() { // Given: a Spin with an initialState @@ -100,11 +105,11 @@ final class UISpinTests: XCTestCase { // When: building a UISpin with the Spin and running the UISpin and emitting an event let sut = UISpin(spin: spin) + AnyPublisher .stream(from: sut) - .output(in: (0...1)) .subscribe() - .disposed(by: &self.disposeBag) + .store(in: &self.subscriptions) sut.emit("newEvent") @@ -139,7 +144,7 @@ final class UISpinTests: XCTestCase { let sut = UISpin(spin: spin) AnyPublisher .start(spin: sut) - .disposed(by: &self.disposeBag) + .store(in: &self.subscriptions) waitForExpectations(timeout: 5) @@ -148,21 +153,26 @@ final class UISpinTests: XCTestCase { } func test_UISpin_runs_the_external_render_function () throws { + let exp = expectation(description: "Render") + // we are awaiting 2 expectations (one for each rendered state initialState/newState) + exp.expectedFulfillmentCount = 2 + // Given: a Spin with an initialState and 1 effect // Given: a SpyRenderer that will render the state mutations - let spyRenderer = SpyRenderer() + let expectedState = "newState" + let expectedExecutionQueue = "com.apple.main-thread" + let spyRenderer = SpyRenderer(expectation: exp) let initialState = "initialState" - let feedback = Feedback(effect: { states in - states.map { state -> String in - return "event" - }.eraseToAnyPublisher() + let feedback = Feedback(effect: { (state: String) -> AnyPublisher in + guard state == "initialState" else { return Empty().eraseToAnyPublisher() } + return Just("event").eraseToAnyPublisher() }) - let reducer = Reducer({ state, _ in + let reducer = Reducer { state, _ in return "newState" - }) + } let spin = Spin(initialState: initialState) { feedback @@ -174,15 +184,15 @@ final class UISpinTests: XCTestCase { let sut = UISpin(spin: spin) sut.render(on: spyRenderer, using: { $0.render(state:) }) - let recorder = AnyPublisher + AnyPublisher .stream(from: sut) - .output(in: (0...2)) - .record() + .sink(receiveCompletion: { _ in }, receiveValue: { _ in }) + .store(in: &self.subscriptions) - _ = try wait(for: recorder.completion, timeout: 5) + waitForExpectations(timeout: 0.5) - // Then: the spyRenderer is called - XCTAssertTrue(spyRenderer.isRenderCalled) - XCTAssertEqual(spyRenderer.receivedState, "newState") + // Then: the spyRenderer is called on the main thread + XCTAssertEqual(spyRenderer.executionQueue, expectedExecutionQueue) + XCTAssertEqual(spyRenderer.receivedState, expectedState) } } diff --git a/Tests/CommonTests/AnySpinTests.swift b/Tests/CommonTests/AnySpinTests.swift index 251c1e5..0cbe6c2 100644 --- a/Tests/CommonTests/AnySpinTests.swift +++ b/Tests/CommonTests/AnySpinTests.swift @@ -20,20 +20,24 @@ final class AnySpinTests: XCTestCase { effectAIsCalled = true return MockStream(value: .toEmpty) } + let effectB = { (states: MockStream) -> MockStream in effectBIsCalled = true return MockStream(value: .toEmpty) } - let scheduledReducer = { (events: MockStream) -> MockStream in + let reducer = { (state: MockState, evernt: MockEvent) -> MockState in reducerIsCalled = true - return MockStream(value: .toEmpty) + return MockState.toEmpty } // When: building an AnySpin based on those feedback stream and reducer - let sut = AnySpin(initialState: MockState(subState: 0), effects: [effectA, effectB], scheduledReducer: scheduledReducer) + let sut = AnySpin, MockStream, MockExecuter>(initialState: MockState(subState: 0), + effects: [effectA, effectB], + reducer: reducer, + executer: MockExecuter()) _ = sut.effects.forEach { _ = $0(MockStream(value: .toEmpty)) } - _ = sut.scheduledReducer(MockStream(value: .toEmpty)) + _ = sut.reducer(MockState.toEmpty, MockEvent.toEmpty) // Then: the AnySpin initializer produces a reactive stream based on those elements XCTAssertEqual(sut.initialState, MockState(subState: 0)) @@ -53,18 +57,19 @@ final class AnySpinTests: XCTestCase { return MockStream(value: .toEmpty) } - let reducerFunction = { (state: MockState, event: MockEvent) -> MockState in + let spyFeedback = MockFeedback(effect: effectFunction) + let spyReducer = Reducer { _, _ in reducerIsCalled = true - return MockState(subState: 0) + return MockState.toEmpty } - let feedback = MockFeedback(effect: effectFunction) - let reducer = MockReducer(reducerFunction) - // When: building an AnySpin based on those feedback and reducer - let sut = AnySpin(initialState: MockState(subState: 0), feedback: feedback, reducer: reducer) + let sut = AnySpin, MockStream, MockExecuter>(initialState: MockState(subState: 0), + feedback: spyFeedback, + reducer: spyReducer, + executeOn: MockExecuter()) _ = sut.effects.forEach { _ = $0(MockStream(value: .toEmpty)) } - _ = sut.scheduledReducer(MockStream(value: .toEmpty)) + _ = sut.reducer(MockState.toEmpty, MockEvent.toEmpty) // Then: the AnySpin initializer produces a reactive stream based on those elements XCTAssertEqual(sut.initialState, MockState(subState: 0)) @@ -89,12 +94,13 @@ final class AnySpinTests: XCTestCase { } // When: building an AnySpin based on those feedback and reducer, with a declarative syntax - let sut = AnySpin(initialState: MockState(subState: 0)) { + let sut = AnySpin, MockStream, MockExecuter>(initialState: MockState(subState: 0), + executeOn: MockExecuter()) { MockFeedback(effect: effectFunction) - MockReducer(reducerFunction) + Reducer(reducerFunction) } _ = sut.effects.forEach { _ = $0(MockStream(value: .toEmpty)) } - _ = sut.scheduledReducer(MockStream(value: .toEmpty)) + _ = sut.reducer(MockState.toEmpty, MockEvent.toEmpty) // Then: the AnySpin initializer produces a reactive stream based on those elements XCTAssertEqual(sut.initialState, MockState(subState: 0)) @@ -125,13 +131,14 @@ final class AnySpinTests: XCTestCase { } // When: building an AnySpin based on those feedbacks and reducer, with a declarative syntax - let sut = AnySpin(initialState: MockState(subState: 0)) { + let sut = AnySpin, MockStream, MockExecuter>(initialState: MockState(subState: 0), + executeOn: MockExecuter()) { MockFeedback(effect: effectAFunction) MockFeedback(effect: effectBFunction) - MockReducer(reducerFunction) + Reducer(reducerFunction) } _ = sut.effects.forEach { _ = $0(MockStream(value: .toEmpty)) } - _ = sut.scheduledReducer(MockStream(value: .toEmpty)) + _ = sut.reducer(MockState.toEmpty, MockEvent.toEmpty) // Then: the AnySpin initializer produces a reactive stream based on those elements XCTAssertEqual(sut.initialState, MockState(subState: 0)) @@ -169,14 +176,15 @@ final class AnySpinTests: XCTestCase { } // When: building an AnySpin based on those feedbacks and reducer, with a declarative syntax - let sut = AnySpin(initialState: MockState(subState: 0)) { + let sut = AnySpin, MockStream, MockExecuter>(initialState: MockState(subState: 0), + executeOn: MockExecuter()) { MockFeedback(effect: effectAFunction) MockFeedback(effect: effectBFunction) MockFeedback(effect: effectCFunction) - MockReducer(reducerFunction) + Reducer(reducerFunction) } _ = sut.effects.forEach { _ = $0(MockStream(value: .toEmpty)) } - _ = sut.scheduledReducer(MockStream(value: .toEmpty)) + _ = sut.reducer(MockState.toEmpty, MockEvent.toEmpty) // Then: the AnySpin initializer produces a reactive stream based on those elements XCTAssertEqual(sut.initialState, MockState(subState: 0)) @@ -221,15 +229,16 @@ final class AnySpinTests: XCTestCase { } // When: building an AnySpin based on those feedbacks and reducer, with a declarative syntax - let sut = AnySpin(initialState: MockState(subState: 0)) { + let sut = AnySpin, MockStream, MockExecuter>(initialState: MockState(subState: 0), + executeOn: MockExecuter()) { MockFeedback(effect: effectAFunction) MockFeedback(effect: effectBFunction) MockFeedback(effect: effectCFunction) MockFeedback(effect: effectDFunction) - MockReducer(reducerFunction) + Reducer(reducerFunction) } _ = sut.effects.forEach { _ = $0(MockStream(value: .toEmpty)) } - _ = sut.scheduledReducer(MockStream(value: .toEmpty)) + _ = sut.reducer(MockState.toEmpty, MockEvent.toEmpty) // Then: the AnySpin initializer produces a reactive stream based on those elements XCTAssertEqual(sut.initialState, MockState(subState: 0)) @@ -281,16 +290,17 @@ final class AnySpinTests: XCTestCase { } // When: building an AnySpin based on those feedbacks and reducer, with a declarative syntax - let sut = AnySpin(initialState: MockState(subState: 0)) { + let sut = AnySpin, MockStream, MockExecuter>(initialState: MockState(subState: 0), + executeOn: MockExecuter()) { MockFeedback(effect: effectAFunction) MockFeedback(effect: effectBFunction) MockFeedback(effect: effectCFunction) MockFeedback(effect: effectDFunction) MockFeedback(effect: effectEFunction) - MockReducer(reducerFunction) + Reducer(reducerFunction) } _ = sut.effects.forEach { _ = $0(MockStream(value: .toEmpty)) } - _ = sut.scheduledReducer(MockStream(value: .toEmpty)) + _ = sut.reducer(MockState.toEmpty, MockEvent.toEmpty) // Then: the AnySpin initializer produces a reactive stream based on those elements XCTAssertEqual(sut.initialState, MockState(subState: 0)) diff --git a/Tests/CommonTests/Mocks/MockExecuter.swift b/Tests/CommonTests/Mocks/MockExecuter.swift index cd43698..fb51c45 100644 --- a/Tests/CommonTests/Mocks/MockExecuter.swift +++ b/Tests/CommonTests/Mocks/MockExecuter.swift @@ -5,8 +5,14 @@ // Created by Thibault Wittemberg on 2019-12-29. // +import SpinCommon import Foundation -struct MockExecuter: Equatable { +struct MockExecuter: ExecuterDefinition, Equatable { + typealias Executer = MockExecuter let id = UUID().uuidString + + static func defaultSpinExecuter() -> Executer { + MockExecuter() + } } diff --git a/Tests/CommonTests/Mocks/MockReducer.swift b/Tests/CommonTests/Mocks/MockReducer.swift deleted file mode 100644 index 8905fa0..0000000 --- a/Tests/CommonTests/Mocks/MockReducer.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// File.swift -// -// -// Created by Thibault Wittemberg on 2020-02-07. -// - -import SpinCommon - -class MockReducer: ReducerDefinition { - typealias StateStream = MockStream - typealias EventStream = MockStream - typealias Executer = MockExecuter - - public let reducer: (StateStream.Value, EventStream.Value) -> StateStream.Value - public let executer: Executer - - required init(_ reducer: @escaping (MockState, MockEvent) -> MockState, on executer: MockExecuter = MockExecuter()) { - self.reducer = reducer - self.executer = executer - } - - public func scheduledReducer(with initialState: StateStream.Value) -> (EventStream) -> StateStream { - return { events in - _ = self.reducer(initialState, MockEvent.toEmpty) - return MockStream(value: .toEmpty) - } - } -} diff --git a/Tests/CommonTests/SpinnerTests.swift b/Tests/CommonTests/SpinnerTests.swift index 9bd5c2f..a73aa68 100644 --- a/Tests/CommonTests/SpinnerTests.swift +++ b/Tests/CommonTests/SpinnerTests.swift @@ -15,8 +15,8 @@ final class SpinnerTests: XCTestCase { let expectedInitialState = MockState(subState: 1701) // When: a Spinner uses the `from` function to build a Spin with the initial state - let sut = Spinner - .initialState(expectedInitialState) + let sut = AnySpinner + .initialState(expectedInitialState, executeOn: MockExecuter()) // Then: the initial state inside the Spinner is the expected one XCTAssertEqual(sut.initialState, expectedInitialState) @@ -32,8 +32,8 @@ final class SpinnerTests: XCTestCase { // When: adding this feedback to a Spinner, resulting in a new SpinnerFeedback // When: executing the feedback stream held by the SpinnerFeedback - let sut = Spinner - .initialState(MockState(subState: 1701)) + let sut = AnySpinner + .initialState(MockState(subState: 1701), executeOn: MockExecuter()) .feedback(feedback) _ = MockFeedback(effects: sut.effects).effect(MockStream(value: .toEmpty)) @@ -60,7 +60,9 @@ final class SpinnerTests: XCTestCase { // When: adding those feedbacks to a Spinner/SpinnerFeedback // When: executing the feedback stream hold by the SpinnerFeedback - let sut = SpinnerFeedback(initialState: MockState(subState: 1701), feedbacks: [feedbackA, feedbackB]) + let sut = SpinnerFeedback, MockStream, MockExecuter>(initialState: MockState(subState: 1701), + feedbacks: [feedbackA, feedbackB], + executer: MockExecuter()) _ = MockFeedback(effects: sut.effects).effect(MockStream(value: .toEmpty)) // Then: the SpinnerFeedback has the original initial state @@ -91,7 +93,9 @@ final class SpinnerTests: XCTestCase { // When: adding those feedbacks to a Spinner/SpinnerFeedback // When: executing the feedback stream hold by the SpinnerFeedback - let sut = SpinnerFeedback(initialState: MockState(subState: 1701), feedbacks: [feedbackA]) + let sut = SpinnerFeedback, MockStream, MockExecuter>(initialState: MockState(subState: 1701), + feedbacks: [feedbackA], + executer: MockExecuter()) .feedback(feedbackB) .feedback(feedbackC) @@ -122,13 +126,14 @@ final class SpinnerTests: XCTestCase { return MockState(subState: 1702) } - let reducer = MockReducer(reducerFunction) + let reducer = Reducer(reducerFunction) // When: using the initial state, the 2 feedbacks and the reducer whithin a Spinner to build a `Spin` - let sut = SpinnerFeedback(initialState: expectedInitialState, - feedbacks: [feedbackA, feedbackB]) + let sut = SpinnerFeedback, MockStream, MockExecuter>(initialState: expectedInitialState, + feedbacks: [feedbackA, feedbackB], + executer: MockExecuter()) .reducer(reducer) - _ = sut.scheduledReducer(MockStream(value: .toEmpty)) + _ = sut.reducer(MockState.toEmpty, MockEvent.toEmpty) // Then: the reducer is called with the right number of feedbacks XCTAssertEqual(sut.initialState, expectedInitialState) diff --git a/Tests/ReactiveSwiftTests/FeedbackTests.swift b/Tests/ReactiveSwiftTests/FeedbackTests.swift index 55e920e..7dc253c 100644 --- a/Tests/ReactiveSwiftTests/FeedbackTests.swift +++ b/Tests/ReactiveSwiftTests/FeedbackTests.swift @@ -9,15 +9,8 @@ import ReactiveSwift @testable import SpinReactiveSwift import XCTest -private class SpyGear: Gear { - func finishEventStream() { - self.eventsObserver.sendCompleted() - } -} - final class FeedbackTests: XCTestCase { - - private let disposeBag = CompositeDisposable() + private let disposables = CompositeDisposable() func test_effect_observes_on_current_executer_when_nilExecuter_is_passed_to_initializer() { var effectIsCalled = false @@ -184,12 +177,12 @@ final class FeedbackTests: XCTestCase { func testFeedback_call_gearSideEffect_and_does_only_trigger_a_feedbackEvent_when_attachment_return_not_nil() { let exp = expectation(description: "attach") - let spyGear = SpyGear() + let gear = Gear() var numberOfCallsGearSideEffect = 0 var receivedEvents = [String]() // Given: a feedback attached to a Gear and triggering en event only of the gear event is 1 - let sut = Feedback(attachedTo: spyGear, propagating: { gearEvent -> String? in + let sut = Feedback(attachedTo: gear, propagating: { gearEvent -> String? in numberOfCallsGearSideEffect += 1 if gearEvent == 1 { return "event" @@ -206,13 +199,13 @@ final class FeedbackTests: XCTestCase { receivedEvents = events exp.fulfill() }) - .disposed(by: self.disposeBag) + .add(to: self.disposables) // When: sending 0 and then 1 as gear event - spyGear.eventsObserver.send(value: 0) - spyGear.eventsObserver.send(value: 1) - spyGear.finishEventStream() - + gear.eventsObserver.send(value: 0) + gear.eventsObserver.send(value: 1) + gear.eventsObserver.sendCompleted() + waitForExpectations(timeout: 0.5) // Then: the gear dedicated side effect is called twice diff --git a/Tests/ReactiveSwiftTests/GearTests.swift b/Tests/ReactiveSwiftTests/GearTests.swift index 655b911..8431c06 100644 --- a/Tests/ReactiveSwiftTests/GearTests.swift +++ b/Tests/ReactiveSwiftTests/GearTests.swift @@ -10,7 +10,7 @@ import ReactiveSwift import XCTest final class GearTests: XCTestCase { - private var subscriptions = CompositeDisposable() + private var disposables = CompositeDisposable() func testPropagate_trigger_eventStream() throws { let exp = expectation(description: "Gear") @@ -25,7 +25,8 @@ final class GearTests: XCTestCase { .startWithValues { value in receivedValue = value exp.fulfill() - }.disposed(by: self.subscriptions) + } + .add(to: self.disposables) // When: propagating en event sut.propagate(event: 1) diff --git a/Tests/ReactiveSwiftTests/ReducerTests.swift b/Tests/ReactiveSwiftTests/ReducerTests.swift deleted file mode 100644 index 136cc24..0000000 --- a/Tests/ReactiveSwiftTests/ReducerTests.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// ReducerTests.swift -// -// -// Created by Thibault Wittemberg on 2019-12-31. -// - -import SpinReactiveSwift -import ReactiveSwift -import XCTest - -final class ReducerTests: XCTestCase { - - private let disposeBag = CompositeDisposable() - - func test_reducer_is_performed_on_default_executer_when_no_executer_is_specified() { - // Given: an event stream switching on a specified Executer after being executed - // and a Reducer applied after this event stream - let exp = expectation(description: "default executer for reducer") - var reduceIsCalled = false - let expectedExecuterName = "com.apple.main-thread" - var receivedExecuterName = "" - - let inputStreamScheduler = QueueScheduler(qos: .background, name: "INPUT_STREAM_QUEUE_\(UUID().uuidString)") - - let eventStream = SignalProducer(value: "").observe(on: inputStreamScheduler) - - let reducerFunction = { (state: String, action: String) -> String in - reduceIsCalled = true - receivedExecuterName = DispatchQueue.currentLabel - exp.fulfill() - return "" - } - - // When: reducing without specifying an Executer for the reduce operation - let sut = Reducer(reducerFunction) - let scheduledReducer = sut.scheduledReducer(with: "initialState") - - _ = scheduledReducer(eventStream) - .take(first: 2) - .start() - .disposed(by: disposeBag) - - waitForExpectations(timeout: 5) - - // Then: the reduce is performed - // Then: the reduce is performed on the default executer, ie the main queue for ReactiveReducer - XCTAssertTrue(reduceIsCalled) - XCTAssertEqual(receivedExecuterName, expectedExecuterName) - } - - func test_reducer_is_performed_on_dedicated_executer_when_executer_is_specified() { - // Given: an effect switching on a specified Executer after being executed - let exp = expectation(description: "default executer for reducer") - var reduceIsCalled = false - let expectedExecuterName = "REDUCER_QUEUE_\(UUID().uuidString)" - var receivedExecuterName = "" - - let inputStreamScheduler = QueueScheduler(qos: .background, name: "INPUT_STREAM_QUEUE_\(UUID().uuidString)") - let reducerScheduler = QueueScheduler(qos: .background, name: expectedExecuterName) - - let eventStream = SignalProducer(value: "").observe(on: inputStreamScheduler) - - let reducerFunction = { (state: String, action: String) -> String in - reduceIsCalled = true - receivedExecuterName = DispatchQueue.currentLabel - exp.fulfill() - return "" - } - - // When: reducing with specifying an Executer for the reduce operation - let sut = Reducer(reducerFunction, on: reducerScheduler) - let scheduledReducer = sut.scheduledReducer(with: "initialState") - - _ = scheduledReducer(eventStream) - .take(first: 2) - .start() - .disposed(by: disposeBag) - - waitForExpectations(timeout: 5) - - // Then: the reduce is performed - // Then: the reduce is performed on the current executer, ie the one set by the effect - XCTAssertTrue(reduceIsCalled) - XCTAssertEqual(receivedExecuterName, expectedExecuterName) - } -} diff --git a/Tests/ReactiveSwiftTests/SignalProducer+ReactiveStreamTests.swift b/Tests/ReactiveSwiftTests/SignalProducer+ReactiveStreamTests.swift index 5ad4de6..ef9dcc0 100644 --- a/Tests/ReactiveSwiftTests/SignalProducer+ReactiveStreamTests.swift +++ b/Tests/ReactiveSwiftTests/SignalProducer+ReactiveStreamTests.swift @@ -11,8 +11,7 @@ import SpinCommon import XCTest final class SignalProducer_ReactiveStream: XCTestCase { - - private let disposeBag = CompositeDisposable() + private let disposables = CompositeDisposable() func test_reactive_stream_is_subscribed_when_spin_is_called() { @@ -28,7 +27,7 @@ final class SignalProducer_ReactiveStream: XCTestCase { exp.fulfill() }) .start() - .disposed(by: self.disposeBag) + .add(to: self.disposables) waitForExpectations(timeout: 5) diff --git a/Tests/ReactiveSwiftTests/SignalProducer+streamFromSpinTests.swift b/Tests/ReactiveSwiftTests/SignalProducer+streamFromSpinTests.swift index dbb6261..e7701c5 100644 --- a/Tests/ReactiveSwiftTests/SignalProducer+streamFromSpinTests.swift +++ b/Tests/ReactiveSwiftTests/SignalProducer+streamFromSpinTests.swift @@ -6,90 +6,91 @@ // import ReactiveSwift +import SpinCommon import SpinReactiveSwift import XCTest final class SignalProducer_streamFromSpinTests: XCTestCase { - - private let disposeBag = CompositeDisposable() - + private let disposables = CompositeDisposable() + func test_initialState_is_the_first_state_given_to_the_effects() { // Given: 2 feedbacks and 1 reducer assembled in a Spin with an initialState let exp = expectation(description: "initialState") let initialState = "initialState" - var receivedInitialStateInEffectA = "" - var receivedInitialStateInEffectB = "" - + var receivedStatesA = [String]() + var receivedStatesB = [String]() + let feedbackA = Feedback(effect: { states in states.map { state -> String in - receivedInitialStateInEffectA = state + receivedStatesA.append(state) return "event" } }) let feedbackB = Feedback(effect: { states in states.map { state -> String in - receivedInitialStateInEffectB = state - exp.fulfill() + receivedStatesB.append(state) return "event" } }) let reducer = Reducer({ state, _ in return "newState" }) - + let spin = Spin(initialState: initialState) { feedbackA feedbackB reducer } - + // When: producing/subscribing to a stream based on the Spin _ = SignalProducer .stream(from: spin) - .take(first: 1) - .start() - .disposed(by: self.disposeBag) - - waitForExpectations(timeout: 5) + .take(first: 2) + .startWithCompleted { exp.fulfill() } + .add(to: self.disposables) + waitForExpectations(timeout: 0.5) + // Then: the feedback's effects receive the initial state - XCTAssertEqual(receivedInitialStateInEffectA, initialState) - XCTAssertEqual(receivedInitialStateInEffectB, initialState) + XCTAssertEqual(receivedStatesA[0], initialState) + XCTAssertEqual(receivedStatesB[0], initialState) } - + func test_initialState_is_the_state_given_to_the_reducer() { + let exp = expectation(description: "Reducer") + // Given: 1 feedback and 1 reducer assembled in a Spin with an initialState - let exp = expectation(description: "initialState") let initialState = "initialState" - var receivedInitialStateInReducer = "" - + var receivedStatesInReducer = [String]() + let feedbackA = Feedback(effect: { states in states.map { state -> String in + print("FEEDBACK state=\(state)") return "event" } }) - + let reducer = Reducer({ state, _ in - receivedInitialStateInReducer = state - exp.fulfill() + print("REDUCER state=\(state)") + receivedStatesInReducer.append(state) return "newState" }) - + let spin = Spin(initialState: initialState) { feedbackA reducer } - + // When: producing/subscribing to a stream based on the ReactiveSpin _ = SignalProducer .stream(from: spin) .take(first: 2) - .start() - .disposed(by: self.disposeBag) - - waitForExpectations(timeout: 5) + .startWithCompleted { exp.fulfill() } + .add(to: self.disposables) + waitForExpectations(timeout: 0.5) + // Then: the reducer receives the initial state - XCTAssertEqual(receivedInitialStateInReducer, initialState) + XCTAssertEqual(receivedStatesInReducer[0], initialState) } } diff --git a/Tests/ReactiveSwiftTests/SpinIntegrationTests.swift b/Tests/ReactiveSwiftTests/SpinIntegrationTests.swift index 0e9a00e..5603cda 100644 --- a/Tests/ReactiveSwiftTests/SpinIntegrationTests.swift +++ b/Tests/ReactiveSwiftTests/SpinIntegrationTests.swift @@ -15,16 +15,16 @@ fileprivate enum StringAction { } final class SpinIntegrationTests: XCTestCase { - - private let disposeBag = CompositeDisposable() + private let disposables = CompositeDisposable() func test_multiple_feedbacks_produces_incremental_states_while_executed_on_default_executer() throws { let exp = expectation(description: "incremental states") - var receivedStates = [String]() + var receivedStatesInEffects = [String]() // Given: an initial state, feedbacks and a reducer var counterA = 0 let effectA = { (state: String) -> SignalProducer in + guard state == "initialState" || state.dropLast().last == "c" else { return .empty } counterA += 1 let counter = counterA return SignalProducer(value: .append("_a\(counter)")) @@ -32,6 +32,7 @@ final class SpinIntegrationTests: XCTestCase { var counterB = 0 let effectB = { (state: String) -> SignalProducer in + guard state.dropLast().last == "a" else { return .empty } counterB += 1 let counter = counterB return SignalProducer(value: .append("_b\(counter)")) @@ -39,11 +40,17 @@ final class SpinIntegrationTests: XCTestCase { var counterC = 0 let effectC = { (state: String) -> SignalProducer in + guard state.dropLast().last == "b" else { return .empty } counterC += 1 let counter = counterC return SignalProducer(value: .append("_c\(counter)")) } + let spyEffect = { (state: String) -> SignalProducer in + receivedStatesInEffects.append(state) + return .empty + } + let reducerFunction = { (state: String, action: StringAction) -> String in switch action { case .append(let suffix): @@ -57,27 +64,25 @@ final class SpinIntegrationTests: XCTestCase { .feedback(Feedback(effect: effectA)) .feedback(Feedback(effect: effectB)) .feedback(Feedback(effect: effectC)) + .feedback(Feedback(effect: spyEffect)) .reducer(Reducer(reducerFunction)) SignalProducer.stream(from: spin) .take(first: 7) .collect() - .startWithValues({ (states) in - receivedStates = states - exp.fulfill() - }) - .disposed(by: self.disposeBag) + .startWithCompleted { exp.fulfill() } + .add(to: self.disposables) - waitForExpectations(timeout: 5) + waitForExpectations(timeout: 1) // Then: the states is constructed incrementally - XCTAssertEqual(receivedStates, ["initialState", - "initialState_a1", - "initialState_a1_b1", - "initialState_a1_b1_c1", - "initialState_a1_b1_c1_a2", - "initialState_a1_b1_c1_a2_b2", - "initialState_a1_b1_c1_a2_b2_c2"]) + XCTAssertEqual(receivedStatesInEffects, ["initialState", + "initialState_a1", + "initialState_a1_b1", + "initialState_a1_b1_c1", + "initialState_a1_b1_c1_a2", + "initialState_a1_b1_c1_a2_b2", + "initialState_a1_b1_c1_a2_b2_c2"]) } func test_multiple_feedbacks_produces_incremental_states_while_executed_on_default_executer_using_declarative_syntax() throws { @@ -128,7 +133,7 @@ final class SpinIntegrationTests: XCTestCase { receivedStates = states exp.fulfill() }) - .disposed(by: self.disposeBag) + .add(to: self.disposables) waitForExpectations(timeout: 5) @@ -191,38 +196,48 @@ extension SpinIntegrationTests { func testAttach_trigger_checkAuthorizationSpin_when_fetchFeatureSpin_trigger_gear() { let exp = expectation(description: "Gear") - var receivedStates = [Any]() + var receivedCheckAuthorization = [CheckAuthorizationSpinState]() + var receivedFeatureStates = [FetchFeatureSpinState]() // Given: 2 independents spins and a shared gear let gear = Gear() let fetchFeatureSpin = self.makeFetchFeatureSpin(attachedTo: gear) let checkAuthorizationSpin = self.makeCheckAuthorizationSpin(attachedTo: gear) + let spyEffectFeatureSpin = { (state: FetchFeatureSpinState) -> SignalProducer in + receivedFeatureStates.append(state) + return .empty + } + fetchFeatureSpin.effects.append(Feedback(effect: spyEffectFeatureSpin).effect) + + let spyEffectCheckAuthorizationSpin = { (state: CheckAuthorizationSpinState) -> SignalProducer in + receivedCheckAuthorization.append(state) + if state == .userHasBeenRevoked { + exp.fulfill() + } + return .empty + } + checkAuthorizationSpin.effects.append(Feedback(effect: spyEffectCheckAuthorizationSpin).effect) + // When: executing the 2 spins SignalProducer .stream(from: checkAuthorizationSpin) - .startWithValues({ state in - receivedStates.append(state) - if state == .userHasBeenRevoked { - exp.fulfill() - } - }) - .disposed(by: self.disposeBag) + .start() + .add(to: self.disposables) SignalProducer .stream(from:fetchFeatureSpin) - .startWithValues({ state in - receivedStates.append(state) - }).disposed(by: self.disposeBag) + .start() + .add(to: self.disposables) waitForExpectations(timeout: 0.5) // Then: the stream of states produced by the spins are the expected one thanks to the propagation of the gear - XCTAssertEqual(receivedStates[0] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.initial) - XCTAssertEqual(receivedStates[1] as? FetchFeatureSpinState, FetchFeatureSpinState.initial) - XCTAssertEqual(receivedStates[2] as? FetchFeatureSpinState, FetchFeatureSpinState.unauthorized) - XCTAssertEqual(receivedStates[3] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.authorizationShouldBeChecked) - XCTAssertEqual(receivedStates[4] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.userHasBeenRevoked) + XCTAssertEqual(receivedCheckAuthorization[0], .initial) + XCTAssertEqual(receivedFeatureStates[0], .initial) + XCTAssertEqual(receivedFeatureStates[1], .unauthorized) + XCTAssertEqual(receivedCheckAuthorization[1], .authorizationShouldBeChecked) + XCTAssertEqual(receivedCheckAuthorization[2], .userHasBeenRevoked) } } diff --git a/Tests/ReactiveSwiftTests/SwiftUISpinTests.swift b/Tests/ReactiveSwiftTests/SwiftUISpinTests.swift index a788f03..526b586 100644 --- a/Tests/ReactiveSwiftTests/SwiftUISpinTests.swift +++ b/Tests/ReactiveSwiftTests/SwiftUISpinTests.swift @@ -7,13 +7,15 @@ import Combine import ReactiveSwift +import SpinCommon import SpinReactiveSwift import XCTest @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) final class SwiftUISpinTests: XCTestCase { - private let disposeBag = CompositeDisposable() + private let disposables = CompositeDisposable() + private var subscriptions = [AnyCancellable]() func test_SwiftUISpin_sets_the_published_state_with_the_initialState_of_the_inner_spin() { // Given: a Spin with an initialState @@ -94,7 +96,7 @@ final class SwiftUISpinTests: XCTestCase { .stream(from: sut) .take(first: 2) .start() - .disposed(by: self.disposeBag) + .add(to: self.disposables) sut.emit("newEvent") @@ -132,7 +134,7 @@ final class SwiftUISpinTests: XCTestCase { .stream(from: sut) .take(first: 2) .start() - .disposed(by: self.disposeBag) + .add(to: self.disposables) let binding = sut.binding(for: \.count, event: { "\($0)" }) binding.wrappedValue = 16 @@ -171,7 +173,7 @@ final class SwiftUISpinTests: XCTestCase { .stream(from: sut) .take(first: 2) .start() - .disposed(by: self.disposeBag) + .add(to: self.disposables) let binding = sut.binding(for: \.count, event: "newEvent") binding.wrappedValue = 16 @@ -207,7 +209,7 @@ final class SwiftUISpinTests: XCTestCase { let sut = SwiftUISpin(spin: spin) SignalProducer .start(spin: sut) - .disposed(by: self.disposeBag) + .add(to: self.disposables) waitForExpectations(timeout: 5) @@ -216,17 +218,19 @@ final class SwiftUISpinTests: XCTestCase { } func test_SwiftUISpin_mutates_the_inner_state() { + let exp = expectation(description: "SwiftUISpin") + // we are expecting 2 fulfillment since the ui state will receive initialState and then newState + exp.expectedFulfillmentCount = 2 + // Given: a Spin with an initialState and 1 effect - let exp = expectation(description: "spin") + let expectedExecutionQueue = "com.apple.main-thread" + var receivedExecutionQueue = "" + let expectedState = "newState" let initialState = "initialState" - let feedback = Feedback(effect: { states in - states.map { state -> String in - if state == "newState" { - exp.fulfill() - } - return "event" - } + let feedback = Feedback(effect: { (state: String) -> SignalProducer in + guard state == "initialState" else { return .empty } + return SignalProducer(value: "event") }) let reducer = Reducer({ state, _ in @@ -238,19 +242,25 @@ final class SwiftUISpinTests: XCTestCase { reducer } - // When: building a UISpin with the Spin + // When: building a SwiftUISpin with the Spin // When: starting the spin - let sut = UISpin(spin: spin) + let sut = SwiftUISpin(spin: spin, extraRenderStateFunction: { + receivedExecutionQueue = DispatchQueue.currentLabel + }) SignalProducer .stream(from: sut) - .take(first: 2) .start() - .disposed(by: self.disposeBag) + .add(to: self.disposables) - waitForExpectations(timeout: 5) + sut.$state.sink { _ in + exp.fulfill() + }.store(in: &self.subscriptions) + + waitForExpectations(timeout: 0.5) - // Then: the state is mutated - XCTAssertEqual(sut.state, "newState") + // Then: the state is mutated on the main thread + XCTAssertEqual(sut.state, expectedState) + XCTAssertEqual(receivedExecutionQueue, expectedExecutionQueue) } } diff --git a/Tests/ReactiveSwiftTests/UISpinTests.swift b/Tests/ReactiveSwiftTests/UISpinTests.swift index 981dfa9..321121d 100644 --- a/Tests/ReactiveSwiftTests/UISpinTests.swift +++ b/Tests/ReactiveSwiftTests/UISpinTests.swift @@ -12,19 +12,23 @@ import SpinCommon import XCTest fileprivate class SpyRenderer { - - var isRenderCalled = false var receivedState = "" + var executionQueue = "" + let expectation: XCTestExpectation + + init(expectation: XCTestExpectation) { + self.expectation = expectation + } func render(state: String) { + self.executionQueue = DispatchQueue.currentLabel self.receivedState = state - self.isRenderCalled = true + self.expectation.fulfill() } } final class UISpinTests: XCTestCase { - - private let disposeBag = CompositeDisposable() + private let disposables = CompositeDisposable() func test_UISpin_sets_the_initial_state_with_the_initialState_of_the_inner_spin() { // Given: a Spin with an initialState @@ -105,7 +109,7 @@ final class UISpinTests: XCTestCase { .stream(from: sut) .take(first: 2) .start() - .disposed(by: self.disposeBag) + .add(to: self.disposables) sut.emit("newEvent") @@ -140,7 +144,7 @@ final class UISpinTests: XCTestCase { let sut = UISpin(spin: spin) SignalProducer .start(spin: sut) - .disposed(by: self.disposeBag) + .add(to: self.disposables) waitForExpectations(timeout: 5) @@ -149,20 +153,21 @@ final class UISpinTests: XCTestCase { } func test_UISpin_runs_the_external_render_function() { + let exp = expectation(description: "Render") + // we are awaiting 2 expectations (one for each rendered state initialState/newState) + exp.expectedFulfillmentCount = 2 + // Given: a Spin with an initialState and 1 effect // Given: a SpyRenderer that will render the state mutations - let exp = expectation(description: "spin") - let spyRenderer = SpyRenderer() + let expectedState = "newState" + let expectedExecutionQueue = "com.apple.main-thread" + let spyRenderer = SpyRenderer(expectation: exp) let initialState = "initialState" - let feedback = Feedback(effect: { states in - states.map { state -> String in - if state == "newState" { - exp.fulfill() - } - return "event" - } + let feedback = Feedback(effect: { (state: String) -> SignalProducer in + guard state == "initialState" else { return .empty } + return SignalProducer(value: "event") }) let reducer = Reducer({ state, _ in @@ -181,14 +186,13 @@ final class UISpinTests: XCTestCase { SignalProducer .stream(from: sut) - .take(first: 2) .start() - .disposed(by: self.disposeBag) + .add(to: self.disposables) - waitForExpectations(timeout: 5) + waitForExpectations(timeout: 0.5) - // Then: the spyRenderer is called - XCTAssertTrue(spyRenderer.isRenderCalled) - XCTAssertEqual(spyRenderer.receivedState, "newState") + // Then: the spyRenderer is called on the main thread + XCTAssertEqual(spyRenderer.executionQueue, expectedExecutionQueue) + XCTAssertEqual(spyRenderer.receivedState, expectedState) } } diff --git a/Tests/RxSwiftTests/FeedbackTests.swift b/Tests/RxSwiftTests/FeedbackTests.swift index b618319..022bc7a 100644 --- a/Tests/RxSwiftTests/FeedbackTests.swift +++ b/Tests/RxSwiftTests/FeedbackTests.swift @@ -10,12 +10,6 @@ import RxSwift @testable import SpinRxSwift import XCTest -private class SpyGear: Gear { - func finishEventStream() { - self.eventSubject.onCompleted() - } -} - final class FeedbackTests: XCTestCase { private let disposeBag = DisposeBag() @@ -233,12 +227,12 @@ final class FeedbackTests: XCTestCase { func testFeedback_call_gearSideEffect_and_does_only_trigger_a_feedbackEvent_when_attachment_return_not_nil() { let exp = expectation(description: "attach") - let spyGear = SpyGear() + let gear = Gear() var numberOfCallsGearSideEffect = 0 var receivedEvents = [String]() // Given: a feedback attached to a Gear and triggering en event only of the gear event is 1 - let sut = Feedback(attachedTo: spyGear, propagating: { gearEvent -> String? in + let sut = Feedback(attachedTo: gear, propagating: { gearEvent -> String? in numberOfCallsGearSideEffect += 1 if gearEvent == 1 { return "event" @@ -250,15 +244,18 @@ final class FeedbackTests: XCTestCase { // When: executing the feedback let inputStream = Observable.just(1701) sut.effect(inputStream) - .do(onNext: { event in receivedEvents.append(event) }) - .do(onCompleted: { exp.fulfill() }) + .do(onNext: { event in + receivedEvents.append(event) + if receivedEvents.count == 1 { + exp.fulfill() + } + }) .subscribe() .disposed(by: self.disposeBag) // When: sending 0 and then 1 as gear event - spyGear.eventSubject.onNext(0) - spyGear.eventSubject.onNext(1) - spyGear.finishEventStream() + gear.eventSubject.accept(0) + gear.eventSubject.accept(1) waitForExpectations(timeout: 0.5) diff --git a/Tests/RxSwiftTests/Observable+streamFromSpinTests.swift b/Tests/RxSwiftTests/Observable+streamFromSpinTests.swift index cb3c3e8..4dedbf3 100644 --- a/Tests/RxSwiftTests/Observable+streamFromSpinTests.swift +++ b/Tests/RxSwiftTests/Observable+streamFromSpinTests.swift @@ -8,6 +8,7 @@ import RxBlocking import RxRelay import RxSwift +import SpinCommon import SpinRxSwift import XCTest @@ -17,18 +18,18 @@ final class Observable_streamFromSpinTests: XCTestCase { func test_initialState_is_the_first_state_given_to_the_effects() { // Given: 2 feedbacks and 1 reducer assembled in a Spin with an initialState let initialState = "initialState" - var receivedInitialStateInEffectA = "" - var receivedInitialStateInEffectB = "" + var receivedStateInEffectA = [String]() + var receivedStateInEffectB = [String]() let feedbackA = Feedback(effect: { states in states.map { state -> String in - receivedInitialStateInEffectA = state + receivedStateInEffectA.append(state) return "event" } }) let feedbackB = Feedback(effect: { states in states.map { state -> String in - receivedInitialStateInEffectB = state + receivedStateInEffectB.append(state) return "event" } }) @@ -43,14 +44,15 @@ final class Observable_streamFromSpinTests: XCTestCase { } // When: producing/subscribing to a stream based on the RxSpin - _ = Observable.stream(from: spin) + _ = Observable + .stream(from: spin) .take(1) .toBlocking() .materialize() // Then: the feedback's effects receive the initial state - XCTAssertEqual(receivedInitialStateInEffectA, initialState) - XCTAssertEqual(receivedInitialStateInEffectB, initialState) + XCTAssertEqual(receivedStateInEffectA[0], initialState) + XCTAssertEqual(receivedStateInEffectB[0], initialState) } func test_initialState_is_the_state_given_to_the_reducer() { @@ -77,7 +79,7 @@ final class Observable_streamFromSpinTests: XCTestCase { // When: producing/subscribing to a stream based on the Spin _ = Observable .stream(from: spin) - .take(2) + .take(1) .toBlocking() .materialize() @@ -114,6 +116,6 @@ final class Observable_streamFromSpinTests: XCTestCase { // Then: the reduce is not performed // Then: the feedback loop completes with no error XCTAssertFalse(reduceIsCalled) - XCTAssertEqual(events, MaterializedSequenceResult.completed(elements: ["initialState"])) + XCTAssertEqual(events, MaterializedSequenceResult.completed(elements: [])) } } diff --git a/Tests/RxSwiftTests/ReducerTests.swift b/Tests/RxSwiftTests/ReducerTests.swift deleted file mode 100644 index e6da35a..0000000 --- a/Tests/RxSwiftTests/ReducerTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -//// -//// ReducerTests.swift -//// -//// -//// Created by Thibault Wittemberg on 2019-12-31. -//// - -import RxBlocking -import RxSwift -import SpinRxSwift -import XCTest - -final class ReducerTests: XCTestCase { - - func test_reducer_is_performed_on_default_executer_when_no_executer_is_specified() { - // Given: an event stream switching on a specified Executer after being executed - // and a Reducer applied after this event stream - var reduceIsCalled = false - let expectedExecuterName = "INPUT_STREAM_QUEUE_\(UUID().uuidString)" - var receivedExecuterName = "" - - let inputStreamScheduler = ConcurrentDispatchQueueScheduler(queue: DispatchQueue(label: expectedExecuterName, - qos: .userInitiated)) - - let eventStream = Observable.just("").observeOn(inputStreamScheduler) - - let reducerFunction = { (state: String, action: String) -> String in - reduceIsCalled = true - receivedExecuterName = DispatchQueue.currentLabel - return "" - } - - // When: reducing without specifying an Executer for the reduce operation - let sut = Reducer(reducerFunction) - let scheduledReducer = sut.scheduledReducer(with: "initialState") - - _ = scheduledReducer(eventStream) - .take(2) - .toBlocking() - .materialize() - - // Then: the reduce is performed - // Then: the reduce is performed on the default executer, ie the CurrentThreadScheduler for RxReducer - XCTAssertTrue(reduceIsCalled) - XCTAssertEqual(receivedExecuterName, expectedExecuterName) - } - - func test_reducer_is_performed_on_dedicated_executer_when_executer_is_specified() { - // Given: an effect switching on a specified Executer after being executed - var reduceIsCalled = false - let expectedExecuterName = "REDUCER_QUEUE_\(UUID().uuidString)" - var receivedExecuterName = "" - - let inputStreamSchedulerQueueLabel = "INPUT_STREAM_QUEUE_\(UUID().uuidString)" - let inputStreamScheduler = ConcurrentDispatchQueueScheduler(queue: DispatchQueue(label: inputStreamSchedulerQueueLabel, - qos: .userInitiated)) - let reducerScheduler = ConcurrentDispatchQueueScheduler(queue: DispatchQueue(label: expectedExecuterName, qos: .userInitiated)) - - let eventStream = Observable.just("").observeOn(inputStreamScheduler) - - let reducerFunction = { (state: String, action: String) -> String in - reduceIsCalled = true - receivedExecuterName = DispatchQueue.currentLabel - return "" - } - - // When: reducing without specifying an Executer for the reduce operation - let sut = Reducer(reducerFunction, on: reducerScheduler) - let scheduledReducer = sut.scheduledReducer(with: "initialState") - - _ = scheduledReducer(eventStream) - .take(2) - .toBlocking() - .materialize() - - // Then: the reduce is performed - // Then: the reduce is performed on the current executer, ie the one set by the feedback - XCTAssertTrue(reduceIsCalled) - XCTAssertEqual(receivedExecuterName, expectedExecuterName) - } -} diff --git a/Tests/RxSwiftTests/SpinIntegrationTests.swift b/Tests/RxSwiftTests/SpinIntegrationTests.swift index 5faa150..3a7c8e9 100644 --- a/Tests/RxSwiftTests/SpinIntegrationTests.swift +++ b/Tests/RxSwiftTests/SpinIntegrationTests.swift @@ -19,9 +19,12 @@ final class SpinIntegrationTests: XCTestCase { private let disposeBag = DisposeBag() func test_multiple_feedbacks_produces_incremental_states_while_executed_on_default_executer() throws { + var receivedStatesInEffects = [String]() + // Given: an initial state, feedbacks and a reducer var counterA = 0 let effectA = { (state: String) -> Observable in + guard state == "initialState" || state.dropLast().last == "c" else { return .empty() } counterA += 1 let counter = counterA return .just(.append("_a\(counter)")) @@ -29,6 +32,7 @@ final class SpinIntegrationTests: XCTestCase { var counterB = 0 let effectB = { (state: String) -> Observable in + guard state.dropLast().last == "a" else { return .empty() } counterB += 1 let counter = counterB return .just(.append("_b\(counter)")) @@ -36,11 +40,17 @@ final class SpinIntegrationTests: XCTestCase { var counterC = 0 let effectC = { (state: String) -> Observable in + guard state.dropLast().last == "b" else { return .empty() } counterC += 1 let counter = counterC return .just(.append("_c\(counter)")) } + let spyEffect = { (state: String) -> Observable in + receivedStatesInEffects.append(state) + return .empty() + } + let reducerFunction = { (state: String, action: StringAction) -> String in switch action { case .append(let suffix): @@ -54,28 +64,31 @@ final class SpinIntegrationTests: XCTestCase { .feedback(Feedback(effect: effectA)) .feedback(Feedback(effect: effectB)) .feedback(Feedback(effect: effectC)) + .feedback(Feedback(effect: spyEffect)) .reducer(Reducer(reducerFunction)) - let receivedStates = try Observable.stream(from: spin) - .take(7) + _ = Observable.stream(from: spin) + .take(6) .toBlocking() - .toArray() + .materialize() // Then: the states is constructed incrementally - XCTAssertEqual(receivedStates, ["initialState", - "initialState_a1", - "initialState_a1_b1", - "initialState_a1_b1_c1", - "initialState_a1_b1_c1_a2", - "initialState_a1_b1_c1_a2_b2", - "initialState_a1_b1_c1_a2_b2_c2"]) + XCTAssertEqual(receivedStatesInEffects, ["initialState", + "initialState_a1", + "initialState_a1_b1", + "initialState_a1_b1_c1", + "initialState_a1_b1_c1_a2", + "initialState_a1_b1_c1_a2_b2", + "initialState_a1_b1_c1_a2_b2_c2"]) } func test_multiple_feedbacks_produces_incremental_states_while_executed_on_default_executer_using_declarative_syntax() throws { + var receivedStatesInEffects = [String]() // Given: an initial state, feedbacks and a reducer var counterA = 0 let effectA = { (state: String) -> Observable in + guard state == "initialState" || state.dropLast().last == "c" else { return .empty() } counterA += 1 let counter = counterA return .just(.append("_a\(counter)")) @@ -83,6 +96,7 @@ final class SpinIntegrationTests: XCTestCase { var counterB = 0 let effectB = { (state: String) -> Observable in + guard state.dropLast().last == "a" else { return .empty() } counterB += 1 let counter = counterB return .just(.append("_b\(counter)")) @@ -90,11 +104,17 @@ final class SpinIntegrationTests: XCTestCase { var counterC = 0 let effectC = { (state: String) -> Observable in + guard state.dropLast().last == "b" else { return .empty() } counterC += 1 let counter = counterC return .just(.append("_c\(counter)")) } + let spyEffect = { (state: String) -> Observable in + receivedStatesInEffects.append(state) + return .empty() + } + let reducerFunction = { (state: String, action: StringAction) -> String in switch action { case .append(let suffix): @@ -103,26 +123,27 @@ final class SpinIntegrationTests: XCTestCase { } let spin = Spin(initialState: "initialState") { - Feedback(effect: effectA).execute(on: MainScheduler.instance) - Feedback(effect: effectB).execute(on: MainScheduler.instance) - Feedback(effect: effectC).execute(on: MainScheduler.instance) + Feedback(effect: effectA) + Feedback(effect: effectB) + Feedback(effect: effectC) + Feedback(effect: spyEffect) Reducer(reducerFunction) } // When: spinning the feedbacks and the reducer on the default executer - let receivedStates = try Observable.stream(from: spin) - .take(7) + _ = Observable.stream(from: spin) + .take(6) .toBlocking() - .toArray() + .materialize() // Then: the states is constructed incrementally - XCTAssertEqual(receivedStates, ["initialState", - "initialState_a1", - "initialState_a1_b1", - "initialState_a1_b1_c1", - "initialState_a1_b1_c1_a2", - "initialState_a1_b1_c1_a2_b2", - "initialState_a1_b1_c1_a2_b2_c2"]) + XCTAssertEqual(receivedStatesInEffects, ["initialState", + "initialState_a1", + "initialState_a1_b1", + "initialState_a1_b1_c1", + "initialState_a1_b1_c1_a2", + "initialState_a1_b1_c1_a2_b2", + "initialState_a1_b1_c1_a2_b2_c2"]) } } @@ -174,39 +195,48 @@ extension SpinIntegrationTests { func testAttach_trigger_checkAuthorizationSpin_when_fetchFeatureSpin_trigger_gear() { let exp = expectation(description: "Gear") - var receivedStates = [Any]() + var receivedCheckAuthorization = [CheckAuthorizationSpinState]() + var receivedFeatureStates = [FetchFeatureSpinState]() // Given: 2 independents spins and a shared gear let gear = Gear() let fetchFeatureSpin = self.makeFetchFeatureSpin(attachedTo: gear) let checkAuthorizationSpin = self.makeCheckAuthorizationSpin(attachedTo: gear) + let spyEffectFeatureSpin = { (state: FetchFeatureSpinState) -> Observable in + receivedFeatureStates.append(state) + return .empty() + } + fetchFeatureSpin.effects.append(Feedback(effect: spyEffectFeatureSpin).effect) + + let spyEffectCheckAuthorizationSpin = { (state: CheckAuthorizationSpinState) -> Observable in + receivedCheckAuthorization.append(state) + if state == .userHasBeenRevoked { + exp.fulfill() + } + return .empty() + } + checkAuthorizationSpin.effects.append(Feedback(effect: spyEffectCheckAuthorizationSpin).effect) + // When: executing the 2 spins Observable .stream(from: checkAuthorizationSpin) - .subscribe(onNext: { state in - receivedStates.append(state) - if state == .userHasBeenRevoked { - exp.fulfill() - } - }) + .subscribe() .disposed(by: self.disposeBag) Observable .stream(from:fetchFeatureSpin) - .subscribe(onNext: { state in - receivedStates.append(state) - }) + .subscribe() .disposed(by: self.disposeBag) waitForExpectations(timeout: 0.5) // Then: the stream of states produced by the spins are the expected one thanks to the propagation of the gear - XCTAssertEqual(receivedStates[0] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.initial) - XCTAssertEqual(receivedStates[1] as? FetchFeatureSpinState, FetchFeatureSpinState.initial) - XCTAssertEqual(receivedStates[2] as? FetchFeatureSpinState, FetchFeatureSpinState.unauthorized) - XCTAssertEqual(receivedStates[3] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.authorizationShouldBeChecked) - XCTAssertEqual(receivedStates[4] as? CheckAuthorizationSpinState, CheckAuthorizationSpinState.userHasBeenRevoked) + XCTAssertEqual(receivedCheckAuthorization[0], .initial) + XCTAssertEqual(receivedFeatureStates[0], .initial) + XCTAssertEqual(receivedFeatureStates[1], .unauthorized) + XCTAssertEqual(receivedCheckAuthorization[1], .authorizationShouldBeChecked) + XCTAssertEqual(receivedCheckAuthorization[2], .userHasBeenRevoked) } } diff --git a/Tests/RxSwiftTests/SwiftUISpinTests.swift b/Tests/RxSwiftTests/SwiftUISpinTests.swift index cb12677..b5490f1 100644 --- a/Tests/RxSwiftTests/SwiftUISpinTests.swift +++ b/Tests/RxSwiftTests/SwiftUISpinTests.swift @@ -7,12 +7,13 @@ import Combine import RxSwift +import SpinCommon import SpinRxSwift import XCTest @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) final class SwiftUISpinTests: XCTestCase { - + private var subscriptions = [AnyCancellable]() private let disposeBag = DisposeBag() func test_SwiftUISpin_sets_the_published_state_with_the_initialState_of_the_inner_spin() { @@ -218,16 +219,17 @@ final class SwiftUISpinTests: XCTestCase { func test_SwiftUISpin_mutates_the_inner_state() { // Given: a Spin with an initialState and 1 effect let exp = expectation(description: "spin") + // we are expecting 2 fulfillment since the ui state will receive initialState and then newState + exp.expectedFulfillmentCount = 2 + let expectedExecutionQueue = "com.apple.main-thread" + var receivedExecutionQueue = "" + let expectedState = "newState" let initialState = "initialState" - let feedback = Feedback(effect: { states in - states.map { state -> String in - if state == "newState" { - exp.fulfill() - } - return "event" - } + let feedback = Feedback(effect: { (state: String) -> Observable in + guard state == "initialState" else { return .empty() } + return .just("event") }) let reducer = Reducer({ state, _ in @@ -239,19 +241,25 @@ final class SwiftUISpinTests: XCTestCase { reducer } - // When: building a UISpin with the Spin + // When: building a SwiftUISpin with the Spin // When: starting the spin - let sut = UISpin(spin: spin) + let sut = SwiftUISpin(spin: spin, extraRenderStateFunction: { + receivedExecutionQueue = DispatchQueue.currentLabel + }) Observable .stream(from: sut) - .take(2) .subscribe() .disposed(by: self.disposeBag) + sut.$state.sink { _ in + exp.fulfill() + }.store(in: &self.subscriptions) + waitForExpectations(timeout: 5) - // Then: the state is mutated - XCTAssertEqual(sut.state, "newState") + // Then: the state is mutated on the main thread + XCTAssertEqual(sut.state, expectedState) + XCTAssertEqual(receivedExecutionQueue, expectedExecutionQueue) } } diff --git a/Tests/RxSwiftTests/UISpinTests.swift b/Tests/RxSwiftTests/UISpinTests.swift index 2f46745..c87af97 100644 --- a/Tests/RxSwiftTests/UISpinTests.swift +++ b/Tests/RxSwiftTests/UISpinTests.swift @@ -7,17 +7,23 @@ import Combine import RxSwift +import SpinCommon import SpinRxSwift import XCTest fileprivate class SpyRenderer { - - var isRenderCalled = false var receivedState = "" + var executionQueue = "" + let expectation: XCTestExpectation + + init(expectation: XCTestExpectation) { + self.expectation = expectation + } func render(state: String) { + self.executionQueue = DispatchQueue.currentLabel self.receivedState = state - self.isRenderCalled = true + self.expectation.fulfill() } } @@ -151,17 +157,17 @@ final class UISpinTests: XCTestCase { // Given: a Spin with an initialState and 1 effect // Given: a SpyRenderer that will render the state mutations let exp = expectation(description: "spin") - let spyRenderer = SpyRenderer() + // we are awaiting 2 expectations (one for each rendered state initialState/newState) + exp.expectedFulfillmentCount = 2 + let expectedState = "newState" + let expectedExecutionQueue = "com.apple.main-thread" + let spyRenderer = SpyRenderer(expectation: exp) let initialState = "initialState" - let feedback = Feedback(effect: { states in - states.map { state -> String in - if state == "newState" { - exp.fulfill() - } - return "event" - } + let feedback = Feedback(effect: { (state: String) -> Observable in + guard state == "initialState" else { return .empty() } + return .just("event") }) let reducer = Reducer({ state, _ in @@ -180,14 +186,13 @@ final class UISpinTests: XCTestCase { Observable .stream(from: sut) - .take(2) .subscribe() .disposed(by: self.disposeBag) - waitForExpectations(timeout: 5) + waitForExpectations(timeout: 0.5) - // Then: the spyRenderer is called - XCTAssertTrue(spyRenderer.isRenderCalled) - XCTAssertEqual(spyRenderer.receivedState, "newState") + // Then: the spyRenderer is called on the main thread + XCTAssertEqual(spyRenderer.executionQueue, expectedExecutionQueue) + XCTAssertEqual(spyRenderer.receivedState, expectedState) } }