diff --git a/.github/spelling-skip-words b/.github/spelling-skip-words index 8e48a9eca..f0f87c808 100644 --- a/.github/spelling-skip-words +++ b/.github/spelling-skip-words @@ -68,3 +68,62 @@ top-100 CocoaPod conformant PromiseKits +CancellablePromise +CancellableThenable +thenable +CancellableCatchable +catchable +cancellableRecover +ensureThen +Alamofire.request +responseDecodable +DecodableObject.self +BFTask +CLLocationManager.requestLocation +URLSession.shared.dataTask +MapKit +MKDirections +URLSession.shared.GET +StoreKit +SKProductsRequest +SystemConfiguration +SCNetworkReachability.promise +UIViewPropertyAnimator +startAnimation +Alamofire.DataRequest +responseData +responseString +responseJSON +responsePropertyList +Alamofire.DownloadRequest +CLLocationManager +requestLocation +authorizationType +requestAuthorization +requestedAuthorizationType +NotificationCenter +NSObject +keyPath +URLSession +dataTask +uploadTask +fromFile +downloadTask +HomeKit +HMPromiseAccessoryBrowser +scanInterval +HMHomeManager +calculateETA +MKMapSnapshotter +SKReceiptRefreshRequest +SCNetworkReachability +Cancellability +HealthKit +enum +cancellize +UIImage +PMKFinalizercancelthendonePromise +VoidPending +PromiseURLSessionresumewait +unusedResult +discardableResultcatchreturncauterize diff --git a/Documents/Cancel.md b/Documents/Cancel.md new file mode 100644 index 000000000..2811019cc --- /dev/null +++ b/Documents/Cancel.md @@ -0,0 +1,464 @@ +# Cancelling Promises + +PromiseKit 7 adds clear and concise cancellation abilities to promises and to the [PromiseKit extensions](#extensions-pane). Cancelling promises and their associated tasks is now simple and straightforward. Promises and promise chains can safely and efficiently be cancelled from any thread at any time. + +```swift +UIApplication.shared.isNetworkActivityIndicatorVisible = true + +let fetchImage = URLSession.shared.dataTask(.promise, with: url).cancellize().compactMap{ UIImage(data: $0.data) } +let fetchLocation = CLLocationManager.requestLocation().cancellize().lastValue + +let finalizer = firstly { + when(fulfilled: fetchImage, fetchLocation) +}.done { image, location in + self.imageView.image = image + self.label.text = "\(location)" +}.ensure { + UIApplication.shared.isNetworkActivityIndicatorVisible = false +}.catch(policy: .allErrors) { error in + /* 'catch' will be invoked with 'PMKError.cancelled' when cancel is called on the context. + Use the default policy of '.allErrorsExceptCancellation' to ignore cancellation errors. */ + self.show(UIAlertController(for: error), sender: self) +} + +//… + +// Cancel currently active tasks and reject all cancellable promises with 'PMKError.cancelled'. +// 'cancel()' can be called from any thread at any time. +finalizer.cancel() + +/* 'finalizer' here refers to the 'CancellableFinalizer' for the chain. Calling 'cancel' on + any promise in the chain or on the finalizer cancels the entire chain. Therefore + calling 'cancel' on the finalizer cancels everything. */ +``` + +# Cancel Chains + +Promises can be cancelled using a `CancellablePromise`. The `cancellize()` method on `Promise` is used to convert a `Promise` into a `CancellablePromise`. If a promise chain is initialized with a `CancellablePromise`, then the entire chain is cancellable. Calling `cancel()` on any promise in the chain cancels the entire chain. + +Creating a chain where the entire chain can be cancelled is the recommended usage for cancellable promises. + +The `CancellablePromise` contains a `CancelContext` that keeps track of the tasks and promises for the chain. Promise chains can be cancelled either by calling the `cancel()` method on any `CancellablePromise` in the chain, or by calling `cancel()` on the `CancelContext` for the chain. It may be desirable to hold on to the `CancelContext` directly rather than a promise so that the promise can be deallocated by ARC when it is resolved. + +For example: + +```swift +let context = firstly { + login() + /* The 'Thenable.cancellize' method initiates a cancellable promise chain by + returning a 'CancellablePromise'. */ +}.cancellize().then { creds in + fetch(avatar: creds.user) +}.done { image in + self.imageView = image +}.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + } +}.cancelContext + +// … + +/* Note: Promises can be cancelled using the 'cancel()' method on the 'CancellablePromise'. + However, it may be desirable to hold on to the 'CancelContext' directly rather than a + promise so that the promise can be deallocated by ARC when it is resolved. */ +context.cancel() +``` + +### Creating a partially cancellable chain + +A `CancellablePromise` can be placed at the start of a chain, but it cannot be embedded directly in the middle of a standard (non-cancellable) promise chain. Instead, a partially cancellable promise chain can be used. A partially cancellable chain is not the recommended way to use cancellable promises, although there may be cases where this is useful. + +**Convert a cancellable chain to a standard chain** + +`CancellablePromise` wraps a delegate `Promise`, which can be accessed with the `promise` property. The above example can be modified as follows so that once `login()` completes, the chain can no longer be cancelled: + +```swift +/* Here, by calling 'promise.then' rather than 'then' the chain is converted from a cancellable + promise chain to a standard promise chain. In this example, calling 'cancel()' during 'login' + will cancel the chain but calling 'cancel()' during the 'fetch' operation will have no effect: */ +let cancellablePromise = firstly { + login().cancellize() +} +cancellablePromise.promise.then { + fetch(avatar: creds.user) +}.done { image in + self.imageView = image +}.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + } +} + +// … + +/* This will cancel the 'login' but will not cancel the 'fetch'. So whether or not the + chain is cancelled depends on how far the chain has progressed. */ +cancellablePromise.cancel() +``` + +**Convert a standard chain to a cancellable chain** + +A non-cancellable chain can be converted to a cancellable chain in the middle of the chain as follows: + +```swift +/* In this example, calling 'cancel()' during 'login' will not cancel the login. However, + the chain will be cancelled immediately, and the 'fetch' will not be executed. If 'cancel()' + is called during the 'fetch' then both the 'fetch' itself and the promise chain will be + cancelled immediately. */ +let promise = firstly { + login() +}.then { + fetch(avatar: creds.user).cancellize() +}.done { image in + self.imageView = image +}.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + } +} + +// … + +promise.cancel() +``` + +# Core Cancellable PromiseKit API + +The following classes, methods and functions have been added to PromiseKit to support cancellation. Existing functions or methods with underlying tasks that can be cancelled are indicated by being appended with '.cancellize()'. + +
Thenable
+    cancellize(_:)                 - Converts the Promise or Guarantee (Thenable) into a
+                                     CancellablePromise, which is a cancellable variant of the given
+                                     Promise or Guarantee (Thenable)
+
+Global functions
+    after(seconds:).cancellize()   - 'after' with seconds can be cancelled
+    after(_:).cancellize           - 'after' with interval can be cancelled
+
+    firstly(execute:)               - Accepts body returning Promise or CancellablePromise
+    hang(_:)                        - Accepts Promise and CancellablePromise
+    race(_:)                        - Accepts [Promise] and [CancellablePromise]
+    when(fulfilled:)                - Accepts [Promise] and [CancellablePromise]
+    when(fulfilled:concurrently:)   - Accepts iterator of type Promise or CancellablePromise
+    when(resolved:)                 - Accepts [Promise] and [CancellablePromise]
+
+CancellablePromise properties and methods
+    promise                         - Delegate Promise for this CancellablePromise
+    result                          - The current Result
+
+    init(_ bridge:cancelContext:)   - Initialize a new cancellable promise bound to the provided Thenable
+    init(cancellable:resolver body:).  - Initialize a new cancellable promise that can be resolved with
+                                       the provided '(Resolver) throws -> Void' body
+    init(cancellable:promise:resolver:)  - Initialize a new cancellable promise using the given Promise
+                                       and its Resolver
+    init(cancellable:error:)          - Initialize a new rejected cancellable promise
+    init(cancellable:)                - Initializes a new cancellable promise fulfilled with Void
+
+    pending() -> (promise:resolver:)  - Returns a tuple of a new cancellable pending promise and its
+                                        Resolver
+
+CancellableThenable properties and methods
+    thenable                        - Delegate Thenable for this CancellableThenable
+
+    cancel(error:)                  - Cancels all members of the promise chain
+    cancelContext                   - The CancelContext associated with this CancellableThenable
+    cancelItemList                  - Tracks the cancel items for this CancellableThenable
+    isCancelled                     - True if all members of the promise chain have been successfully
+                                      cancelled, false otherwise
+    cancelAttempted                 - True if 'cancel' has been called on the promise chain associated
+                                      with this CancellableThenable, false otherwise
+    cancelledError                  - The error generated when the promise is cancelled
+    appendCancellable(cancellable:reject:)  - Append the Cancellable task to our cancel context
+    appendCancelContext(from:)      - Append the cancel context associated with 'from' to our
+                                      CancelContext
+
+    then(on:flags:_ body:)           - Accepts body returning CancellableThenable
+    cancellableThen(on:flags:_ body:)  - Accepts body returning Thenable
+    map(on:flags:_ transform:)
+    compactMap(on:flags:_ transform:)
+    done(on:flags:_ body:)
+    get(on:flags:_ body:)
+    tap(on:flags:_ body:)
+    asVoid()
+
+    error
+    isPending
+    isResolved
+    isFulfilled
+    isRejected
+    value
+
+    mapValues(on:flags:_ transform:)
+    flatMapValues(on:flags:_ transform:)
+    compactMapValues(on:flags:_ transform:)
+    thenMap(on:flags:_ transform:)                 - Accepts transform returning CancellableThenable
+    cancellableThenMap(on:flags:_ transform:)      - Accepts transform returning Thenable
+    thenFlatMap(on:flags:_ transform:)             - Accepts transform returning CancellableThenable
+    cancellableThenFlatMap(on:flags:_ transform:)  - Accepts transform returning Thenable
+    filterValues(on:flags:_ isIncluded:)
+    firstValue
+    lastValue
+    sortedValues(on:flags:)
+
+CancellableCatchable properties and methods
+    catchable                                      - Delegate Catchable for this CancellableCatchable
+    catch(on:flags:policy::_ body:)                - Accepts body returning Void
+    recover(on:flags:policy::_ body:)              - Accepts body returning CancellableThenable
+    cancellableRecover(on:flags:policy::_ body:)   - Accepts body returning Thenable
+    ensure(on:flags:_ body:)                       - Accepts body returning Void
+    ensureThen(on:flags:_ body:)                   - Accepts body returning CancellablePromise
+    finally(_ body:)
+    cauterize()
+
+ +# Extensions + +Cancellation support has been added to the PromiseKit extensions, but only where the underlying asynchronous tasks can be cancelled. This example Podfile lists the PromiseKit extensions that support cancellation along with a usage example: + +
pod "PromiseKit/Alamofire"
+# Alamofire.request("http://example.com", method: .get).responseDecodable(DecodableObject.self).cancellize()
+
+pod "PromiseKit/Bolts"
+# CancellablePromise(…).then() { _ -> BFTask in /*…*/ }  // Returns CancellablePromise
+
+pod "PromiseKit/CoreLocation"
+# CLLocationManager.requestLocation().cancellize().then { /*…*/ }
+
+pod "PromiseKit/Foundation"
+# URLSession.shared.dataTask(.promise, with: request).cancellize().then { /*…*/ }
+
+pod "PromiseKit/MapKit"
+# MKDirections(…).calculate().cancellize().then { /*…*/ }
+
+pod "PromiseKit/OMGHTTPURLRQ"
+# URLSession.shared.GET("http://example.com").cancellize().then { /*…*/ }
+
+pod "PromiseKit/StoreKit"
+# SKProductsRequest(…).start(.promise).cancellize().then { /*…*/ }
+
+pod "PromiseKit/SystemConfiguration"
+# SCNetworkReachability.promise().cancellize().then { /*…*/ }
+
+pod "PromiseKit/UIKit"
+# UIViewPropertyAnimator(…).startAnimation(.promise).cancellize().then { /*…*/ }
+
+ +Here is a complete list of PromiseKit extension methods that support cancellation: + +[Alamofire](http://github.com/PromiseKit/Alamofire-) + +
Alamofire.DataRequest
+    response(_:queue:).cancellize()
+    responseData(queue:).cancellize()
+    responseString(queue:).cancellize()
+    responseJSON(queue:options:).cancellize()
+    responsePropertyList(queue:options:).cancellize()
+    responseDecodable(queue::decoder:).cancellize()
+    responseDecodable(_ type:queue:decoder:).cancellize()
+
+Alamofire.DownloadRequest
+    response(_:queue:).cancellize()
+    responseData(queue:).cancellize()
+
+ +[Bolts](http://github.com/PromiseKit/Bolts) + +
CancellablePromise<T>
+    then<U>(on: DispatchQueue?, body: (T) -> BFTask<U>) -> CancellablePromise
+
+ +[CoreLocation](http://github.com/PromiseKit/CoreLocation) + +
CLLocationManager
+    requestLocation(authorizationType:satisfying:).cancellize()
+    requestAuthorization(type requestedAuthorizationType:).cancellize()
+
+ +[Foundation](http://github.com/PromiseKit/Foundation) + +
NotificationCenter:
+    observe(once:object:).cancellize()
+
+NSObject
+    observe(_:keyPath:).cancellize()
+
+Process
+    launch(_:).cancellize()
+
+URLSession
+    dataTask(_:with:).cancellize()
+    uploadTask(_:with:from:).cancellize()
+    uploadTask(_:with:fromFile:).cancellize()
+    downloadTask(_:with:to:).cancellize()
+
+CancellablePromise
+    validate()
+
+ +[HomeKit](http://github.com/PromiseKit/HomeKit) + +
HMPromiseAccessoryBrowser
+    start(scanInterval:).cancellize()
+
+HMHomeManager
+    homes().cancellize()
+
+ +[MapKit](http://github.com/PromiseKit/MapKit) + +
MKDirections
+    calculate().cancellize()
+    calculateETA().cancellize()
+
+MKMapSnapshotter
+    start().cancellize()
+
+ +[StoreKit](http://github.com/PromiseKit/StoreKit) + +
SKProductsRequest
+    start(_:).cancellize()
+
+SKReceiptRefreshRequest
+    promise().cancellize()
+
+ +[SystemConfiguration](http://github.com/PromiseKit/SystemConfiguration) + +
SCNetworkReachability
+    promise().cancellize()
+
+ +[UIKit](http://github.com/PromiseKit/UIKit) + +
UIViewPropertyAnimator
+    startAnimation(_:).cancellize()
+
+ +## Choose Your Networking Library + +All the networking library extensions supported by PromiseKit are now simple to cancel! + +[Alamofire](http://github.com/PromiseKit/Alamofire-) + +```swift +// pod 'PromiseKit/Alamofire' +// # https://github.com/PromiseKit/Alamofire + +let context = firstly { + Alamofire + .request("http://example.com", method: .post, parameters: params) + .responseDecodable(Foo.self) +}.cancellize().done { foo in + //… +}.catch { error in + //… +}.cancelContext + +//… + +context.cancel() +``` + +And (of course) plain `URLSession` from [Foundation](http://github.com/PromiseKit/Foundation): + +```swift +// pod 'PromiseKit/Foundation' +// # https://github.com/PromiseKit/Foundation + +let context = firstly { + URLSession.shared.dataTask(.promise, with: try makeUrlRequest()) +}.cancellize().map { + try JSONDecoder().decode(Foo.self, with: $0.data) +}.done { foo in + //… +}.catch { error in + //… +}.cancelContext + +//… + +context.cancel() + +func makeUrlRequest() throws -> URLRequest { + var rq = URLRequest(url: url) + rq.httpMethod = "POST" + rq.addValue("application/json", forHTTPHeaderField: "Content-Type") + rq.addValue("application/json", forHTTPHeaderField: "Accept") + rq.httpBody = try JSONSerialization.jsonData(with: obj) + return rq +} +``` + +# Cancellability Goals + +* Provide a streamlined way to cancel a promise chain, which rejects all associated promises and cancels all associated tasks. For example: + +```swift +let promise = firstly { + login() +}.cancellize().then { creds in // Use the 'cancellize' function to initiate a cancellable promise chain + fetch(avatar: creds.user) +}.done { image in + self.imageView = image +}.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + } +} +//… +promise.cancel() +``` + +* Ensure that subsequent code blocks in a promise chain are _never_ called after the chain has been cancelled + +* Fully support concurrency, where all code is thread-safe. Cancellable promises and promise chains can safely and efficiently be cancelled from any thread at any time. + +* Provide cancellable support for all PromiseKit extensions whose native tasks can be cancelled (e.g. Alamofire, Bolts, CoreLocation, Foundation, HealthKit, HomeKit, MapKit, StoreKit, SystemConfiguration, UIKit) + +* Support cancellation for all PromiseKit primitives such as 'after', 'firstly', 'when', 'race' + +* Provide a simple way to make new types of cancellable promises + +* Ensure promise branches are properly cancelled. For example: + +```swift +import Alamofire +import PromiseKit + +func updateWeather(forCity searchName: String) { + refreshButton.startAnimating() + let context = firstly { + getForecast(forCity: searchName) + }.cancellize().done { response in + updateUI(forecast: response) + }.ensure { + refreshButton.stopAnimating() + }.catch { error in + // Cancellation errors are ignored by default + showAlert(error: error) + }.cancelContext + + //… + + /* **** Cancels EVERYTHING (except... the 'ensure' block always executes regardless) + Note: non-cancellable tasks cannot be interrupted. For example: if 'cancel()' is + called in the middle of 'updateUI()' then the chain will immediately be rejected, + however the 'updateUI' call will complete normally because it is not cancellable. + Its return value (if any) will be discarded. */ + context.cancel() +} + +func getForecast(forCity name: String) -> CancellablePromise { + return firstly { + Alamofire.request("https://autocomplete.weather.com/\(name)") + .responseDecodable(AutoCompleteCity.self) + }.cancellize().then { city in + Alamofire.request("https://forecast.weather.com/\(city.name)") + .responseDecodable(WeatherResponse.self).cancellize() + }.map { response in + format(response) + } +} +``` diff --git a/Documents/CommonPatterns.md b/Documents/CommonPatterns.md index 398affc60..8be09e415 100644 --- a/Documents/CommonPatterns.md +++ b/Documents/CommonPatterns.md @@ -212,22 +212,42 @@ one promise at a time if you need to. ```swift let fetches: [Promise] = makeFetches() -let timeout = after(seconds: 4) -race(when(fulfilled: fetches).asVoid(), timeout).then { +race(when(fulfilled: fetches).asVoid(), timeout(seconds: 4)).then { //… +}.catch(policy: .allErrors) { + // Rejects with 'PMKError.timedOut' if the timeout is exceeded } ``` `race` continues as soon as one of the promises it is watching finishes. +`timeout(seconds: TimeInterval)` returns a promise that throws +`PMKError.timedOut` when the time interval is exceeded. Note that `PMKError.timedOut` +is a cancellation error therefore the `.allErrors` catch policy must be specified +to handle this exception. + Make sure the promises you pass to `race` are all of the same type. The easiest way to ensure this is to use `asVoid()`. Note that if any component promise rejects, the `race` will reject, too. +When used with cancellable promises, all promises will be cancelled if either the timeout is +exceeded or if any promise rejects. + +```swift +let fetches: [Promise] = makeFetches() +let cancellableFetches: [CancellablePromise] = fetches.map { return $0.cancellize() } + +// All promises are automatically cancelled if any of them reject. +race(when(fulfilled: cancellableFetches).asVoid(), timeout(seconds: 4).cancellize()).then { + //… +}.catch(policy: .allErrors) { + // Rejects with 'PMKError.timedOut' if the timeout is exceeded. +} +``` -# Minimum Duration +## Minimum Duration Sometimes you need a task to take *at least* a certain amount of time. (For example, you want to show a progress spinner, but if it shows for less than 0.3 seconds, the UI @@ -245,61 +265,22 @@ firstly { } ``` -The code above works because we create the delay *before* we do work in `foo()`. By the +The code above works because we create the delay *before* we do work in `foo()`. By the time we get to waiting on that promise, either it will have already timed out or we will wait for whatever remains of the 0.3 seconds before continuing the chain. ## Cancellation -Promises don’t have a `cancel` function, but they do support cancellation through a -special error type that conforms to the `CancellableError` protocol. - -```swift -func foo() -> (Promise, cancel: () -> Void) { - let task = Task(…) - var cancelme = false - - let promise = Promise { seal in - task.completion = { value in - guard !cancelme else { return reject(PMKError.cancelled) } - seal.fulfill(value) - } - task.start() - } - - let cancel = { - cancelme = true - task.cancel() - } - - return (promise, cancel) -} -``` - -Promises don’t have a `cancel` function because you don’t want code outside of -your control to be able to cancel your operations--*unless*, of course, you explicitly -want to enable that behavior. In cases where you do want cancellation, the exact way -that it should work will vary depending on how the underlying task supports cancellation. -PromiseKit provides cancellation primitives but no concrete API. - -Cancelled chains do not call `catch` handlers by default. However you can -intercept cancellation if you like: - -```swift -foo.then { - //… -}.catch(policy: .allErrors) { - // cancelled errors are handled *as well* -} -``` +Starting with version 7, PromiseKit explicitly supports cancellation of promises and +promise chains. There is a new class called `CancellablePromise` that defines a `cancel` +method. Use the `cancellize` method on `Thenable` to obtain a `CancellablePromise` from a +`Promise` or `Guarantee`. -**Important**: Canceling a promise chain is *not* the same as canceling the underlying -asynchronous task. Promises are wrappers around asynchronicity, but they have no -control over the underlying tasks. If you need to cancel an underlying task, you -need to cancel the underlying task! +Invoking `cancel` will both reject the promise with `PMKError.cancelled` and cancel any +underlying asynchronous task(s). -> The library [CancellablePromiseKit](https://github.com/johannesd/CancellablePromiseKit) extends the concept of Promises to fully cover cancellable tasks. +For full details see [Cancelling Promises](Cancel.md). ## Retry / Polling diff --git a/Documents/README.md b/Documents/README.md index 8164ddfec..20da99cd0 100644 --- a/Documents/README.md +++ b/Documents/README.md @@ -4,6 +4,7 @@ * Handbook * [Getting Started](GettingStarted.md) * [Promises: Common Patterns](CommonPatterns.md) + * [Cancelling Promises](Cancel.md) * [Frequently Asked Questions](FAQ.md) * Manual * [Installation Guide](Installation.md) diff --git a/Documents/Troubleshooting.md b/Documents/Troubleshooting.md index 417fe17c2..218112124 100644 --- a/Documents/Troubleshooting.md +++ b/Documents/Troubleshooting.md @@ -163,6 +163,84 @@ An *inline* function like this is all you need. Here, the problem is that you forgot to mark the last line of the closure with an explicit `return`. It's required here because the closure is longer than one line. +### Cancellable promise embedded in the middle of a standard promise chain + +Error: ***Cannot convert value of type 'Promise<>' to closure result type 'Guarantee<>'***. Fixed by adding `cancellize` to `firstly { login() }`. + +```swift +/// 'login()' returns 'Promise' +/// 'fetch(avatar:)' returns 'CancellablePromise' + +let promise = firstly { + login() /// <-- ERROR: Cannot convert value of type 'Promise' to closure result type 'Guarantee' +}.then { creds in /// CHANGE TO: "}.cancellize().then { creds in" + fetch(avatar: creds.user) /// <-- ERROR: Cannot convert value of type 'CancellablePromise' to + /// closure result type 'Guarantee' +}.done { image in + self.imageView = image +}.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + } +} + +// … + +promise.cancel() +``` + +### The return type for a multi-line closure returning `CancellablePromise` is not explicitly stated + +The Swift compiler cannot (yet) determine the return type of a multi-line closure. + +The following example gives the unhelpful error: ***'()' is not convertible to 'UIImage'***. Many other strange errors can result from not explicitly declaring the return type of a multi-line closure. These kinds of errors are fixed by explicitly declaring the return type, which in the following example is a `CancellablePromise``. + +```swift +/// 'login()' returns 'Promise' +/// 'fetch(avatar:)' returns 'CancellablePromise' + +let promise = firstly { + login() +}.cancellize().then { creds in /// CHANGE TO: "}.cancellize().then { creds -> CancellablePromise in" + let f = fetch(avatar: creds.user) + return f +}.done { image in + self.imageView = image /// <-- ERROR: '()' is not convertible to 'UIImage' +}.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + } +} + +// … + +promise.cancel() +``` + +### Trying to cancel a standard promise chain + +Error: ***Value of type `PMKFinalizer` has no member `cancel`***. Fixed by using cancellable promises instead of standard promises. + +```swift +/// 'login()' returns 'Promise' +/// 'fetch(avatar:)' returns 'CancellablePromise' + +let promise = firstly { + login() +}.then { creds in /// CHANGE TO: "}.cancellize().then { creds in" + fetch(avatar: creds.user).promise /// CHANGE TO: fetch(avatar: creds.user) +}.done { image in + self.imageView = image +}.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + } +} + +// … + +promise.cancel() /// <-- ERROR: Value of type 'PMKFinalizer' has no member 'cancel' +``` ## You copied code off the Internet that doesn’t work diff --git a/Package.swift b/Package.swift index 4c5e54ce9..748cc1d70 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ pkg.swiftLanguageVersions = [ pkg.targets = [ .target(name: "PromiseKit", path: "Sources"), .testTarget(name: "Core", dependencies: ["PromiseKit"], path: "Tests/Core"), + .testTarget(name: "Cancel", dependencies: ["PromiseKit"], path: "Tests/Cancel"), .testTarget(name: "A+.swift", dependencies: ["PromiseKit"], path: "Tests/A+/Swift"), .testTarget(name: "A+.js", dependencies: ["PromiseKit"], path: "Tests/A+/JavaScript"), ] diff --git a/README.md b/README.md index 86c77c4f4..28369124f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,9 @@ PromiseKit 7 is prerelease, if you’re using it: beware! PromiseKit 7 uses Swift 5’s `Result`, PromiseKit <7 use our own `Result` type. PromiseKit 7 generalizes `DispatchQueue`s to a `Dispatcher` protocol. However, `DispatchQueue`s are `Dispatcher`-conformant, -so existing code should not need to change. Please report any issues related to this transition. +so existing code should not need to change. Please report any issues related to this transition. + +PromiseKit 7 adds support for cancelling promises and promise chains. # Quick Start @@ -95,6 +97,7 @@ help me continue my work, I appreciate it 🙏🏻 * Handbook * [Getting Started](Documents/GettingStarted.md) * [Promises: Common Patterns](Documents/CommonPatterns.md) + * [Cancelling Promises](Documents/Cancel.md) * [Frequently Asked Questions](Documents/FAQ.md) * Manual * [Installation Guide](Documents/Installation.md) diff --git a/Sources/CancelContext.swift b/Sources/CancelContext.swift new file mode 100644 index 000000000..2e9ae677d --- /dev/null +++ b/Sources/CancelContext.swift @@ -0,0 +1,257 @@ +import Dispatch +import Foundation + +/** + Keeps track of all promises in a promise chain with pending or currently running tasks, and cancels them all when `cancel` is called. + */ +public class CancelContext: Hashable { + public static func == (lhs: CancelContext, rhs: CancelContext) -> Bool { + return lhs === rhs + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + // Create a barrier queue that is used as a read/write lock for the CancelContext + // For reads: barrier.sync { } + // For writes: barrier.sync(flags: .barrier) { } + private let barrier = DispatchQueue(label: "org.promisekit.barrier.cancel", attributes: .concurrent) + + private var cancelItems = [CancelItem]() + private var cancelItemSet = Set() + + /** + Cancel all members of the promise chain and their associated asynchronous operations. + + - Parameter error: Specifies the cancellation error to use for the cancel operation, defaults to `PMKError.cancelled` + */ + public func cancel(with error: Error = PMKError.cancelled) { + self.cancel(with: error, visited: Set()) + } + + func cancel(with error: Error = PMKError.cancelled, visited: Set) { + var items: [CancelItem]! + barrier.sync(flags: .barrier) { + internalCancelledError = error + items = cancelItems + } + + for item in items { + item.cancel(with: error, visited: visited) + } + } + + /** + True if all members of the promise chain have been successfully cancelled, false otherwise. + */ + public var isCancelled: Bool { + var items: [CancelItem]! + barrier.sync { + items = cancelItems + } + + for item in items where !item.isCancelled { + return false + } + return true + } + + /** + True if `cancel` has been called on the CancelContext associated with this promise, false otherwise. `cancelAttempted` will be true if `cancel` is called on any promise in the chain. + */ + public var cancelAttempted: Bool { + return cancelledError != nil + } + + private var internalCancelledError: Error? + + /** + The cancellation error initialized when the promise is cancelled, or `nil` if not cancelled. + */ + public private(set) var cancelledError: Error? { + get { + var err: Error! + barrier.sync { + err = internalCancelledError + } + return err + } + + set { + barrier.sync(flags: .barrier) { + internalCancelledError = newValue + } + } + } + + func append(cancellable: Cancellable?, reject: ((Error) -> Void)?, thenable: Z) { + if cancellable == nil && reject == nil { + return + } + let item = CancelItem(cancellable: cancellable, reject: reject) + + var error: Error? + barrier.sync(flags: .barrier) { + error = internalCancelledError + cancelItems.append(item) + cancelItemSet.insert(item) + thenable.cancelItemList.append(item) + } + + if error != nil { + item.cancel(with: error!) + } + } + + func append(context childContext: CancelContext, thenable: Z) { + guard childContext !== self else { + return + } + let item = CancelItem(context: childContext) + + var error: Error? + barrier.sync(flags: .barrier) { + error = internalCancelledError + cancelItems.append(item) + cancelItemSet.insert(item) + thenable.cancelItemList.append(item) + } + + crossCancel(childContext: childContext, parentCancelledError: error) + } + + func append(context childContext: CancelContext, thenableCancelItemList: CancelItemList) { + guard childContext !== self else { + return + } + let item = CancelItem(context: childContext) + + var error: Error? + barrier.sync(flags: .barrier) { + error = internalCancelledError + cancelItems.append(item) + cancelItemSet.insert(item) + thenableCancelItemList.append(item) + } + + crossCancel(childContext: childContext, parentCancelledError: error) + } + + private func crossCancel(childContext: CancelContext, parentCancelledError: Error?) { + let parentError = parentCancelledError + let childError = childContext.cancelledError + + if parentError != nil { + if childError == nil { + childContext.cancel(with: parentError!) + } + } else if childError != nil { + if parentError == nil { + cancel(with: childError!) + } + } + } + + func recover() { + cancelledError = nil + } + + func removeItems(_ list: CancelItemList, clearList: Bool) -> Error? { + var error: Error? + barrier.sync(flags: .barrier) { + error = internalCancelledError + if error == nil && list.items.count != 0 { + var currentIndex = 1 + // The `list` parameter should match a block of items in the cancelItemList, remove them from the cancelItemList + // in one operation for efficiency + if cancelItemSet.remove(list.items[0]) != nil { + let removeIndex = cancelItems.firstIndex(of: list.items[0])! + while currentIndex < list.items.count { + let item = list.items[currentIndex] + if item != cancelItems[removeIndex + currentIndex] { + break + } + cancelItemSet.remove(item) + currentIndex += 1 + } + cancelItems.removeSubrange(removeIndex..<(removeIndex+currentIndex)) + } + + // Remove whatever falls outside of the block + while currentIndex < list.items.count { + let item = list.items[currentIndex] + if cancelItemSet.remove(item) != nil { + cancelItems.remove(at: cancelItems.firstIndex(of: item)!) + } + currentIndex += 1 + } + + if clearList { + list.removeAll() + } + } + } + return error + } +} + +/// Tracks the cancel items for a CancellablePromise. These items are removed from the associated CancelContext when the promise resolves. +public class CancelItemList { + fileprivate var items: [CancelItem] + + init() { + self.items = [] + } + + fileprivate func append(_ item: CancelItem) { + items.append(item) + } + + fileprivate func removeAll() { + items.removeAll() + } +} + +fileprivate class CancelItem: Hashable { + static func == (lhs: CancelItem, rhs: CancelItem) -> Bool { + return lhs === rhs + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } + + let cancellable: Cancellable? + var reject: ((Error) -> Void)? + weak var context: CancelContext? + var cancelAttempted = false + + init(cancellable: Cancellable?, reject: ((Error) -> Void)?) { + self.cancellable = cancellable + self.reject = reject + } + + init(context: CancelContext) { + self.cancellable = nil + self.context = context + } + + func cancel(with error: Error, visited: Set? = nil) { + cancelAttempted = true + + cancellable?.cancel() + reject?(error) + + if var v = visited, let c = context { + if !v.contains(c) { + v.insert(c) + c.cancel(with: error, visited: v) + } + } + } + + var isCancelled: Bool { + return cancellable?.isCancelled ?? cancelAttempted + } +} diff --git a/Sources/CancellableCatchable.swift b/Sources/CancellableCatchable.swift new file mode 100644 index 000000000..9a53fd642 --- /dev/null +++ b/Sources/CancellableCatchable.swift @@ -0,0 +1,301 @@ +import Dispatch + +/// Provides `catch` and `recover` to your object that conforms to `CancellableThenable` +public protocol CancellableCatchMixin: CancellableThenable { + /// Type of the delegate `catchable` + associatedtype C: CatchMixin + + /// Delegate `catchable` for this CancellablePromise + var catchable: C { get } +} + +public extension CancellableCatchMixin { + /** + The provided closure executes when this cancellable promise rejects. + + Rejecting a promise cascades: rejecting all subsequent promises (unless + recover is invoked) thus you will typically place your catch at the end + of a chain. Often utility promises will not have a catch, instead + delegating the error handling to the caller. + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter policy: The default policy does not execute your handler for cancellation errors. + - Parameter execute: The handler to execute if this promise is rejected. + - Returns: A promise finalizer. + - SeeAlso: [Cancellation](https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#cancellation) + */ + @discardableResult + func `catch`(on: Dispatcher = conf.D.return, policy: CatchPolicy = conf.catchPolicy, _ body: @escaping(Error) -> Void) -> CancellableFinalizer { + return CancellableFinalizer(self.catchable.catch(on: on, policy: policy, body), cancel: self.cancelContext) + } +} + +/** + Cancellable finalizer returned from `catch`. Use `finally` to specify a code block that executes when the promise chain resolves. + */ +public class CancellableFinalizer { + let pmkFinalizer: PMKFinalizer + + /// The CancelContext associated with this finalizer + public let cancelContext: CancelContext + + init(_ pmkFinalizer: PMKFinalizer, cancel: CancelContext) { + self.pmkFinalizer = pmkFinalizer + self.cancelContext = cancel + } + + /// `finally` is the same as `ensure`, but it is not chainable + @discardableResult + public func finally(on: Dispatcher = conf.D.return, _ body: @escaping () -> Void) -> CancelContext { + pmkFinalizer.finally(on: on, body) + return cancelContext + } + + /** + Cancel all members of the promise chain and their associated asynchronous operations. + + - Parameter error: Specifies the cancellation error to use for the cancel operation, defaults to `PMKError.cancelled` + */ + public func cancel(with error: Error = PMKError.cancelled) { + cancelContext.cancel(with: error) + } + + /** + True if all members of the promise chain have been successfully cancelled, false otherwise. + */ + public var isCancelled: Bool { + return cancelContext.isCancelled + } + + /** + True if `cancel` has been called on the CancelContext associated with this promise, false otherwise. `cancelAttempted` will be true if `cancel` is called on any promise in the chain. + */ + public var cancelAttempted: Bool { + return cancelContext.cancelAttempted + } + + /** + The cancellation error generated when the promise is cancelled, or `nil` if not cancelled. + */ + public var cancelledError: Error? { + return cancelContext.cancelledError + } +} + +public extension CancellableCatchMixin { + /** + The provided closure executes when this cancellable promise rejects. + + Unlike `catch`, `recover` continues the chain. + Use `recover` in circumstances where recovering the chain from certain errors is a possibility. For example: + + let context = firstly { + CLLocationManager.requestLocation() + }.recover { error in + guard error == CLError.unknownLocation else { throw error } + return .value(CLLocation.chicago) + }.cancelContext + + //… + + context.cancel() + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The handler to execute if this promise is rejected. + - SeeAlso: [Cancellation](https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#cancellation) + */ + func recover(on: Dispatcher = conf.D.map, policy: CatchPolicy = conf.catchPolicy, _ body: @escaping(Error) throws -> V) -> CancellablePromise where V.U.T == C.T { + + let cancelItemList = CancelItemList() + + let cancelBody = { (error: Error) throws -> V.U in + _ = self.cancelContext.removeItems(self.cancelItemList, clearList: true) + let rval = try body(error) + if policy == .allErrors { + self.cancelContext.recover() + } + self.cancelContext.append(context: rval.cancelContext, thenableCancelItemList: cancelItemList) + return rval.thenable + } + + let promise = self.catchable.recover(on: on, policy: policy, cancelBody) + if thenable.result != nil && policy == .allErrors { + self.cancelContext.recover() + } + return CancellablePromise(promise: promise, context: self.cancelContext, cancelItemList: cancelItemList) + } + + /** + The provided closure executes when this cancellable promise rejects. + + Unlike `catch`, `recover` continues the chain. + Use `recover` in circumstances where recovering the chain from certain errors is a possibility. For example: + + let context = firstly { + CLLocationManager.requestLocation() + }.cancellize().recover { error in + guard error == CLError.unknownLocation else { throw error } + return .value(CLLocation.chicago) + }.cancelContext + + //… + + context.cancel() + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The handler to execute if this promise is rejected. + - SeeAlso: [Cancellation](https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#cancellation) + - Note: Methods with the `cancellable` prefix create a new CancellablePromise, and those without the `cancellable` prefix accept an existing CancellablePromise. + */ + func recover(on: Dispatcher = conf.D.map, policy: CatchPolicy = conf.catchPolicy, _ body: @escaping(Error) throws -> V) -> CancellablePromise where V.T == C.T { + + let cancelBody = { (error: Error) throws -> V in + _ = self.cancelContext.removeItems(self.cancelItemList, clearList: true) + let rval = try body(error) + if policy == .allErrors { + self.cancelContext.recover() + } + return rval + } + + let promise = self.catchable.recover(on: on, policy: policy, cancelBody) + if thenable.result != nil && policy == .allErrors { + self.cancelContext.recover() + } + let cancellablePromise = CancellablePromise(promise: promise, context: self.cancelContext) + if let cancellable = promise.cancellable { + self.cancelContext.append(cancellable: cancellable, reject: promise.rejectIfCancelled, thenable: cancellablePromise) + } + return cancellablePromise + } + + /** + The provided closure executes when this cancellable promise resolves, whether it rejects or not. + + let context = firstly { + UIApplication.shared.networkActivityIndicatorVisible = true + //… returns a cancellable promise + }.done { + //… + }.ensure { + UIApplication.shared.networkActivityIndicatorVisible = false + }.catch { + //… + }.cancelContext + + //… + + context.cancel() + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The closure that executes when this promise resolves. + - Returns: A new promise, resolved with this promise’s resolution. + */ + func ensure(on: Dispatcher = conf.D.return, _ body: @escaping () -> Void) -> CancellablePromise { + let rp = CancellablePromise.pending() + rp.promise.cancelContext = self.cancelContext + self.catchable.pipe { result in + on.dispatch { + body() + switch result { + case .success(let value): + if let error = self.cancelContext.cancelledError { + rp.resolver.reject(error) + } else { + rp.resolver.fulfill(value) + } + case .failure(let error): + rp.resolver.reject(error) + } + } + } + return rp.promise + } + + /** + The provided closure executes when this cancellable promise resolves, whether it rejects or not. + The chain waits on the returned `CancellablePromise`. + + let context = firstly { + setup() // returns a cancellable promise + }.done { + //… + }.ensureThen { + teardown() // -> CancellablePromise + }.catch { + //… + }.cancelContext + + //… + + context.cancel() + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The closure that executes when this promise resolves. + - Returns: A new promise, resolved with this promise’s resolution. + */ + func ensureThen(on: Dispatcher = conf.D.return, _ body: @escaping () -> CancellablePromise) -> CancellablePromise { + let rp = CancellablePromise.pending() + rp.promise.cancelContext = cancelContext + self.catchable.pipe { result in + on.dispatch { + let rv = body() + rp.promise.appendCancelContext(from: rv) + + rv.done { + switch result { + case .success(let value): + if let error = self.cancelContext.cancelledError { + rp.resolver.reject(error) + } else { + rp.resolver.fulfill(value) + } + case .failure(let error): + rp.resolver.reject(error) + } + }.catch(policy: .allErrors) { + rp.resolver.reject($0) + } + } + } + return rp.promise + } + + /** + Consumes the Swift unused-result warning. + - Note: You should `catch`, but in situations where you know you don’t need a `catch`, `cauterize` makes your intentions clear. + */ + @discardableResult + func cauterize() -> CancellableFinalizer { + return self.catch(policy: .allErrors) { + Swift.print("PromiseKit:cauterized-error:", $0) + } + } +} + +public extension CancellableCatchMixin where C.T == Void { + /** + The provided closure executes when this cancellable promise rejects. + + This variant of `recover` ensures that no error is thrown from the handler and allows specifying a catch policy. + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The handler to execute if this promise is rejected. + - SeeAlso: [Cancellation](https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#cancellation) + */ + func recover(on: Dispatcher = conf.D.map, policy: CatchPolicy = conf.catchPolicy, _ body: @escaping(Error) throws -> Void) -> CancellablePromise { + let cancelBody = { (error: Error) throws -> Void in + _ = self.cancelContext.removeItems(self.cancelItemList, clearList: true) + try body(error) + if policy == .allErrors { + self.cancelContext.recover() + } + } + + let promise = self.catchable.recover(on: on, policy: policy, cancelBody) + if thenable.result != nil && policy == .allErrors { + self.cancelContext.recover() + } + return CancellablePromise(promise: promise, context: self.cancelContext) + } +} diff --git a/Sources/CancellablePromise.swift b/Sources/CancellablePromise.swift new file mode 100644 index 000000000..212d8777d --- /dev/null +++ b/Sources/CancellablePromise.swift @@ -0,0 +1,143 @@ +import class Foundation.Thread +import Dispatch + +/** + A `CancellablePromise` is a functional abstraction around a failable and cancellable asynchronous operation. + + At runtime the promise can become a member of a chain of promises, where the `cancelContext` is used to track and cancel (if desired) all promises in this chain. + + - See: `CancellableThenable` + */ +public class CancellablePromise: CancellableThenable, CancellableCatchMixin { + /// Delegate `promise` for this CancellablePromise + public let promise: Promise + + /// Type of the delegate `thenable` + public typealias U = Promise + + /// Delegate `thenable` for this CancellablePromise + public var thenable: U { + return promise + } + + /// Type of the delegate `catchable` + public typealias C = Promise + + /// Delegate `catchable` for this CancellablePromise + public var catchable: C { + return promise + } + + /// The CancelContext associated with this CancellablePromise + public var cancelContext: CancelContext + + /// Tracks the cancel items for this CancellablePromise. These items are removed from the associated CancelContext when the promise resolves. + public var cancelItemList: CancelItemList + + init(promise: Promise, context: CancelContext? = nil, cancelItemList: CancelItemList? = nil) { + self.promise = promise + self.cancelContext = context ?? CancelContext() + self.cancelItemList = cancelItemList ?? CancelItemList() + } + + /// Initialize a new rejected cancellable promise. + public convenience init(cancellable: Cancellable? = nil, error: Error) { + var reject: ((Error) -> Void)! + self.init(promise: Promise { seal in + reject = seal.reject + seal.reject(error) + }) + self.appendCancellable(cancellable, reject: reject) + } + + /// Initialize a new cancellable promise bound to the provided `Thenable`. + public convenience init(_ bridge: U, cancelContext: CancelContext? = nil) where U.T == T { + var promise: Promise! + let cancellable: Cancellable! + var reject: ((Error) -> Void)! + + if let p = bridge as? Promise { + cancellable = p.cancellable + if let r = p.rejectIfCancelled { + promise = p + reject = r + } + } else if let g = bridge as? Guarantee { + cancellable = g.cancellable + } else { + cancellable = nil + } + + if promise == nil { + // Wrapper promise + promise = Promise { seal in + reject = seal.reject + bridge.done(on: nil) { + seal.fulfill($0) + }.catch(policy: .allErrors) { + seal.reject($0) + } + } + } + + self.init(promise: promise, context: cancelContext) + self.appendCancellable(cancellable, reject: reject) + } + + /// Initialize a new cancellable promise that can be resolved with the provided `Resolver`. + public convenience init(cancellable: Cancellable? = nil, resolver body: (Resolver) throws -> Void) { + var reject: ((Error) -> Void)! + self.init(promise: Promise { seal in + reject = seal.reject + try body(seal) + }) + self.appendCancellable(cancellable, reject: reject) + } + + /// Initialize a new cancellable promise using the given Promise and its Resolver. + public convenience init(cancellable: Cancellable? = nil, promise: Promise, resolver: Resolver) { + self.init(promise: promise) + self.appendCancellable(cancellable, reject: resolver.reject) + } + + /// - Returns: a tuple of a new cancellable pending promise and its `Resolver`. + public class func pending() -> (promise: CancellablePromise, resolver: Resolver) { + let rp = Promise.pending() + return (promise: CancellablePromise(promise: rp.promise), resolver: rp.resolver) + } + + /// Internal function required for `Thenable` conformance. + /// - See: `Thenable.pipe` + public func pipe(to: @escaping (Result) -> Void) { + promise.pipe(to: to) + } + + /// - Returns: The current `Result` for this cancellable promise. + /// - See: `Thenable.result` + public var result: Result? { + return promise.result + } + + /** + Blocks this thread, so—you know—don’t call this on a serial thread that + any part of your chain may use. Like the main thread for example. + */ + public func wait() throws -> T { + return try promise.wait() + } +} + +#if swift(>=3.1) +extension CancellablePromise where T == Void { + /// Initializes a new cancellable promise fulfilled with `Void` + public convenience init() { + self.init(promise: Promise()) + } + + /// Initializes a new cancellable promise fulfilled with `Void` and with the given ` Cancellable` + public convenience init(cancellable: Cancellable) { + self.init() + self.appendCancellable(cancellable, reject: nil) + } +} +#endif diff --git a/Sources/CancellableTask.swift b/Sources/CancellableTask.swift new file mode 100644 index 000000000..28b57f5a4 --- /dev/null +++ b/Sources/CancellableTask.swift @@ -0,0 +1,12 @@ +import Dispatch + +/** + Use this protocol to define cancellable tasks for CancellablePromise. + */ +public protocol Cancellable { + /// Cancel the associated task + func cancel() + + /// `true` if the task was successfully cancelled, `false` otherwise + var isCancelled: Bool { get } +} diff --git a/Sources/CancellableThenable.swift b/Sources/CancellableThenable.swift new file mode 100644 index 000000000..646ada924 --- /dev/null +++ b/Sources/CancellableThenable.swift @@ -0,0 +1,520 @@ +import Dispatch + +/** + CancellableThenable represents an asynchronous operation that can be both chained and cancelled. When chained, all CancellableThenable members of the chain are cancelled when `cancel` is called on the associated CancelContext. + */ +public protocol CancellableThenable: class { + /// Type of the delegate `thenable` + associatedtype U: Thenable + + /// Delegate `thenable` for this `CancellableThenable` + var thenable: U { get } + + /// The `CancelContext` associated with this `CancellableThenable` + var cancelContext: CancelContext { get } + + /// Tracks the cancel items for this `CancellableThenable`. These items are removed from the associated `CancelContext` when the thenable resolves. + var cancelItemList: CancelItemList { get } +} + +public extension CancellableThenable { + /// Append the `task` and `reject` function for a cancellable task to the cancel context + func appendCancellable(_ cancellable: Cancellable?, reject: ((Error) -> Void)?) { + self.cancelContext.append(cancellable: cancellable, reject: reject, thenable: self) + } + + /// Append the cancel context associated with `from` to our cancel context. Typically `from` is a branch of our chain. + func appendCancelContext(from: Z) { + self.cancelContext.append(context: from.cancelContext, thenable: self) + } + + /** + Cancel all members of the promise chain and their associated asynchronous operations. + + - Parameter error: Specifies the cancellation error to use for the cancel operation, defaults to `PMKError.cancelled` + */ + func cancel(with error: Error = PMKError.cancelled) { + self.cancelContext.cancel(with: error) + } + + /** + True if all members of the promise chain have been successfully cancelled, false otherwise. + */ + var isCancelled: Bool { + return self.cancelContext.isCancelled + } + + /** + True if `cancel` has been called on the CancelContext associated with this promise, false otherwise. `cancelAttempted` will be true if `cancel` is called on any promise in the chain. + */ + var cancelAttempted: Bool { + return self.cancelContext.cancelAttempted + } + + /** + The cancellation error generated when the promise is cancelled, or `nil` if not cancelled. + */ + var cancelledError: Error? { + return self.cancelContext.cancelledError + } + + /** + The provided closure executes when this cancellable promise resolves. + + This allows chaining promises. The cancellable promise returned by the provided closure is resolved before the cancellable promise returned by this closure resolves. + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The closure that executes when this cancellable promise fulfills. It must return a cancellable promise. + - Returns: A new cancellable promise that resolves when the cancellable promise returned from the provided closure resolves. For example: + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url1) + }.cancellize().then { response in + transform(data: response.data) // returns a CancellablePromise + }.done { transformation in + //… + }.cancelContext + + //… + + context.cancel() + */ + func then(on: Dispatcher = conf.D.map, _ body: @escaping (U.T) throws -> V) -> CancellablePromise { + + let cancelItemList = CancelItemList() + + let cancelBody = { (value: U.T) throws -> V.U in + if let error = self.cancelContext.removeItems(self.cancelItemList, clearList: true) { + throw error + } else { + let rv = try body(value) + self.cancelContext.append(context: rv.cancelContext, thenableCancelItemList: cancelItemList) + return rv.thenable + } + } + + let promise = self.thenable.then(on: on, cancelBody) + return CancellablePromise(promise: promise, context: self.cancelContext, cancelItemList: cancelItemList) + } + + /** + The provided closure executes when this cancellable promise resolves. + + This allows chaining promises. The promise returned by the provided closure is resolved before the cancellable promise returned by this closure resolves. + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The closure that executes when this promise fulfills. It must return a promise (not a cancellable promise). + - Returns: A new cancellable promise that resolves when the promise returned from the provided closure resolves. For example: + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url1) + }.cancellize().then { response in + transform(data: response.data) // returns a Promise + }.done { transformation in + //… + }.cancelContext + + //… + + context.cancel() + */ + func then(on: Dispatcher = conf.D.map, _ body: @escaping (U.T) throws -> V) -> CancellablePromise { + let cancelBody = { (value: U.T) throws -> V in + if let error = self.cancelContext.removeItems(self.cancelItemList, clearList: true) { + throw error + } else { + return try body(value) + } + } + + let promise = self.thenable.then(on: on, cancelBody) + return CancellablePromise(promise, cancelContext: self.cancelContext) + } + + /** + The provided closure is executed when this cancellable promise is resolved. + + This is like `then` but it requires the closure to return a non-promise and non-cancellable-promise. + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter transform: The closure that is executed when this CancellablePromise is fulfilled. It must return a non-promise and non-cancellable-promise. + - Returns: A new cancellable promise that is resolved with the value returned from the provided closure. For example: + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url1) + }.cancellize().map { response in + response.data.length + }.done { length in + //… + }.cancelContext + + //… + + context.cancel() + */ + func map(on: Dispatcher = conf.D.map, _ transform: @escaping (U.T) throws -> V) -> CancellablePromise { + let cancelTransform = { (value: U.T) throws -> V in + if let error = self.cancelContext.removeItems(self.cancelItemList, clearList: true) { + throw error + } else { + return try transform(value) + } + } + + let promise = self.thenable.map(on: on, cancelTransform) + return CancellablePromise(promise: promise, context: self.cancelContext) + } + + /** + The provided closure is executed when this cancellable promise is resolved. + + In your closure return an `Optional`, if you return `nil` the resulting cancellable promise is rejected with `PMKError.compactMap`, otherwise the cancellable promise is fulfilled with the unwrapped value. + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url) + }.cancellize().compactMap { + try JSONSerialization.jsonObject(with: $0.data) as? [String: String] + }.done { dictionary in + //… + }.catch { + // either `PMKError.compactMap` or a `JSONError` + }.cancelContext + + //… + + context.cancel() + */ + func compactMap(on: Dispatcher = conf.D.map, _ transform: @escaping (U.T) throws -> V?) -> CancellablePromise { + let cancelTransform = { (value: U.T) throws -> V? in + if let error = self.cancelContext.removeItems(self.cancelItemList, clearList: true) { + throw error + } else { + return try transform(value) + } + } + + let promise = self.thenable.compactMap(on: on, cancelTransform) + return CancellablePromise(promise: promise, context: self.cancelContext) + } + + /** + The provided closure is executed when this cancellable promise is resolved. + + Equivalent to `map { x -> Void in`, but since we force the `Void` return Swift + is happier and gives you less hassle about your closure’s qualification. + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The closure that is executed when this promise is fulfilled. + - Returns: A new cancellable promise fulfilled as `Void`. + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url) + }.cancellize().done { response in + print(response.data) + }.cancelContext + + //… + + context.cancel() + */ + func done(on: Dispatcher = conf.D.return, _ body: @escaping (U.T) throws -> Void) -> CancellablePromise { + let cancelBody = { (value: U.T) throws -> Void in + if let error = self.cancelContext.removeItems(self.cancelItemList, clearList: true) { + throw error + } else { + try body(value) + } + } + + let promise = self.thenable.done(on: on, cancelBody) + return CancellablePromise(promise: promise, context: self.cancelContext) + } + + /** + The provided closure is executed when this cancellable promise is resolved. + + This is like `done` but it returns the same value that the handler is fed. + `get` immutably accesses the fulfilled value; the returned CancellablePromise maintains that value. + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The closure that is executed when this promise is fulfilled. + - Returns: A new cancellable promise that is resolved with the value that the handler is fed. For example: + + let context = firstly { + cancellize(Promise.value(1)) + }.get { foo in + print(foo, " is 1") + }.done { foo in + print(foo, " is 1") + }.done { foo in + print(foo, " is Void") + }.cancelContext + + //… + + context.cancel() + */ + func get(on: Dispatcher = conf.D.return, _ body: @escaping (U.T) throws -> Void) -> CancellablePromise { + return map(on: on) { + try body($0) + return $0 + } + } + + /** + The provided closure is executed with cancellable promise result. + + This is like `get` but provides the Result of the CancellablePromise so you can inspect the value of the chain at this point without causing any side effects. + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The closure that is executed with Result of CancellablePromise. + - Returns: A new cancellable promise that is resolved with the result that the handler is fed. For example: + + promise.tap{ print($0) }.then{ /*…*/ } + */ + func tap(on: Dispatcher = conf.D.map, _ body: @escaping(Result) -> Void) -> CancellablePromise { + let rp = CancellablePromise.pending() + rp.promise.cancelContext = self.cancelContext + self.thenable.pipe { result in + on.dispatch { + if let error = self.cancelContext.removeItems(self.cancelItemList, clearList: true) { + rp.resolver.reject(error) + } else { + body(result) + rp.resolver.resolve(result) + } + } + } + return rp.promise + } + + /// - Returns: a new cancellable promise chained off this cancellable promise but with its value discarded. + func asVoid() -> CancellablePromise { + return map(on: nil) { _ in } + } +} + +public extension CancellableThenable { + /** + - Returns: The error with which this cancellable promise was rejected; `nil` if this promise is not rejected. + */ + var error: Error? { + return thenable.error + } + + /** + - Returns: `true` if the cancellable promise has not yet resolved. + */ + var isPending: Bool { + return thenable.isPending + } + + /** + - Returns: `true` if the cancellable promise has resolved. + */ + var isResolved: Bool { + return thenable.isResolved + } + + /** + - Returns: `true` if the cancellable promise was fulfilled. + */ + var isFulfilled: Bool { + return thenable.isFulfilled + } + + /** + - Returns: `true` if the cancellable promise was rejected. + */ + var isRejected: Bool { + return thenable.isRejected + } + + /** + - Returns: The value with which this cancellable promise was fulfilled or `nil` if this cancellable promise is pending or rejected. + */ + var value: U.T? { + return thenable.value + } +} + +public extension CancellableThenable where U.T: Sequence { + /** + `CancellablePromise<[U.T]>` => `U.T` -> `V` => `CancellablePromise<[V]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.mapValues { integer in + integer * 2 + }.done { + // $0 => [2,4,6] + } + */ + func mapValues(on: Dispatcher = conf.D.map, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V]> { + return map(on: on) { try $0.map(transform) } + } + + /** + `CancellablePromise<[U.T]>` => `U.T` -> `[V]` => `CancellablePromise<[V]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.flatMapValues { integer in + [integer, integer] + }.done { + // $0 => [1,1,2,2,3,3] + } + */ + func flatMapValues(on: Dispatcher = conf.D.map, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.Iterator.Element]> { + return map(on: on) { (foo: U.T) in + try foo.flatMap { try transform($0) } + } + } + + /** + `CancellablePromise<[U.T]>` => `U.T` -> `V?` => `CancellablePromise<[V]>` + + firstly { + cancellize(Promise.value(["1","2","a","3"])) + }.compactMapValues { + Int($0) + }.done { + // $0 => [1,2,3] + } + */ + func compactMapValues(on: Dispatcher = conf.D.map, _ transform: @escaping(U.T.Iterator.Element) throws -> V?) -> CancellablePromise<[V]> { + return map(on: on) { foo -> [V] in + return try foo.compactMap(transform) + } + } + + /** + `CancellablePromise<[U.T]>` => `U.T` -> `CancellablePromise` => `CancellablePromise<[V]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.thenMap { integer in + cancellize(Promise.value(integer * 2)) + }.done { + // $0 => [2,4,6] + } + */ + func thenMap(on: Dispatcher = conf.D.map, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.U.T]> { + return then(on: on) { + when(fulfilled: try $0.map(transform)) + } + } + + /** + `CancellablePromise<[U.T]>` => `U.T` -> `Promise` => `CancellablePromise<[V]>` + + firstly { + Promise.value([1,2,3]) + }.cancellize().thenMap { integer in + .value(integer * 2) + }.done { + // $0 => [2,4,6] + } + */ + func thenMap(on: Dispatcher = conf.D.map, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.T]> { + return then(on: on) { + when(fulfilled: try $0.map(transform)) + } + } + + /** + `CancellablePromise<[T]>` => `T` -> `CancellablePromise<[U]>` => `CancellablePromise<[U]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.thenFlatMap { integer in + cancellize(Promise.value([integer, integer])) + }.done { + // $0 => [1,1,2,2,3,3] + } + */ + func thenFlatMap(on: Dispatcher = conf.D.map, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.U.T.Iterator.Element]> where V.U.T: Sequence { + return then(on: on) { + when(fulfilled: try $0.map(transform)) + }.map(on: nil) { + $0.flatMap { $0 } + } + } + + /** + `CancellablePromise<[T]>` => `T` -> `Promise<[U]>` => `CancellablePromise<[U]>` + + firstly { + Promise.value([1,2,3]) + }.cancellize().thenFlatMap { integer in + .value([integer, integer]) + }.done { + // $0 => [1,1,2,2,3,3] + } + */ + func thenFlatMap(on: Dispatcher = conf.D.map, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.T.Iterator.Element]> where V.T: Sequence { + return then(on: on) { + when(fulfilled: try $0.map(transform)) + }.map(on: nil) { + $0.flatMap { $0 } + } + } + + /** + `CancellablePromise<[T]>` => `T` -> Bool => `CancellablePromise<[U]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.filterValues { + $0 > 1 + }.done { + // $0 => [2,3] + } + */ + func filterValues(on: Dispatcher = conf.D.map, _ isIncluded: @escaping (U.T.Iterator.Element) -> Bool) -> CancellablePromise<[U.T.Iterator.Element]> { + return map(on: on) { + $0.filter(isIncluded) + } + } +} + +public extension CancellableThenable where U.T: Collection { + /// - Returns: a cancellable promise fulfilled with the first value of this `Collection` or, if empty, a promise rejected with PMKError.emptySequence. + var firstValue: CancellablePromise { + return map(on: nil) { aa in + if let a1 = aa.first { + return a1 + } else { + throw PMKError.emptySequence + } + } + } + + func firstValue(on: Dispatcher = conf.D.map, where test: @escaping (U.T.Iterator.Element) -> Bool) -> CancellablePromise { + return map(on: on) { + for x in $0 where test(x) { + return x + } + throw PMKError.emptySequence + } + } + + /// - Returns: a cancellable promise fulfilled with the last value of this `Collection` or, if empty, a promise rejected with PMKError.emptySequence. + var lastValue: CancellablePromise { + return map(on: nil) { aa in + if aa.isEmpty { + throw PMKError.emptySequence + } else { + let i = aa.index(aa.endIndex, offsetBy: -1) + return aa[i] + } + } + } +} + +public extension CancellableThenable where U.T: Sequence, U.T.Iterator.Element: Comparable { + /// - Returns: a cancellable promise fulfilled with the sorted values of this `Sequence`. + func sortedValues(on: Dispatcher = conf.D.map) -> CancellablePromise<[U.T.Iterator.Element]> { + return map(on: on) { $0.sorted() } + } +} diff --git a/Sources/Dispatcher.swift b/Sources/Dispatcher.swift index 835cb6fd4..0000e5e79 100644 --- a/Sources/Dispatcher.swift +++ b/Sources/Dispatcher.swift @@ -541,3 +541,481 @@ public extension CatchMixin where T == Void { return recover(on: dispatcher, policy: policy, body) } } + +//////////////////////////////////////////////////////////// Cancellation + +public extension CancellableThenable { + /** + The provided closure executes when this cancellable promise resolves. + + This allows chaining promises. The cancellable promise returned by the provided closure is resolved before the cancellable promise returned by this closure resolves. + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter body: The closure that executes when this cancellable promise fulfills. It must return a cancellable promise. + - Returns: A new cancellable promise that resolves when the promise returned from the provided closure resolves. For example: + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url1) + }.cancellize().then { response in + transform(data: response.data) // returns a CancellablePromise + }.done { transformation in + //… + }.cancelContext + + //… + + context.cancel() + */ + func then(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ body: @escaping (U.T) throws -> V) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return then(on: dispatcher, body) + } + + /** + The provided closure executes when this cancellable promise resolves. + + This allows chaining promises. The promise returned by the provided closure is resolved before the cancellable promise returned by this closure resolves. + + - Parameter on: The dispatcher that executes the provided closure. + - Parameter body: The closure that executes when this cancellable promise fulfills. It must return a promise (not a cancellable promise). + - Returns: A new cancellable promise that resolves when the promise returned from the provided closure resolves. For example: + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url1) + }.cancellize().then { response in + transform(data: response.data) // returns a Promise + }.done { transformation in + //… + }.cancelContext + + //… + + context.cancel() + */ + func then(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ body: @escaping (U.T) throws -> V) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return then(on: dispatcher, body) + } + + /** + The provided closure is executed when this cancellable promise is resolved. + + This is like `then` but it requires the closure to return a non-promise and non-cancellable-promise. + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter transform: The closure that is executed when this CancellablePromise is fulfilled. It must return a non-promise and non-cancellable-promise. + - Returns: A new cancellable promise that is resolved with the value returned from the provided closure. For example: + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url1) + }.cancellize().map { response in + response.data.length + }.done { length in + //… + }.cancelContext + + //… + + context.cancel() + */ + func map(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping (U.T) throws -> V) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return map(on: dispatcher, transform) + } + + /** + The provided closure is executed when this cancellable promise is resolved. + + In your closure return an `Optional`, if you return `nil` the resulting cancellable promise is rejected with `PMKError.compactMap`, otherwise the cancellable promise is fulfilled with the unwrapped value. + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url) + }.cancellize().compactMap { + try JSONSerialization.jsonObject(with: $0.data) as? [String: String] + }.done { dictionary in + //… + }.catch { + // either `PMKError.compactMap` or a `JSONError` + }.cancelContext + + //… + + context.cancel() + */ + func compactMap(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping (U.T) throws -> V?) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return compactMap(on: dispatcher, transform) + } + + /** + The provided closure is executed when this cancellable promise is resolved. + + Equivalent to `map { x -> Void in`, but since we force the `Void` return Swift + is happier and gives you less hassle about your closure’s qualification. + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter body: The closure that is executed when this promise is fulfilled. + - Returns: A new cancellable promise fulfilled as `Void`. + + let context = firstly { + URLSession.shared.dataTask(.promise, with: url) + }.cancellize().done { response in + print(response.data) + }.cancelContext + + //… + + context.cancel() + */ + func done(on: DispatchQueue? = conf.Q.return, flags: DispatchWorkItemFlags? = nil, _ body: @escaping (U.T) throws -> Void) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.return, flags: flags) + return done(on: dispatcher, body) + } + + /** + The provided closure is executed when this cancellable promise is resolved. + + This is like `done` but it returns the same value that the handler is fed. + `get` immutably accesses the fulfilled value; the returned CancellablePromise maintains that value. + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter body: The closure that is executed when this CancellablePromise is fulfilled. + - Returns: A new cancellable promise that is resolved with the value that the handler is fed. For example: + + let context = firstly { + cancellize(Promise.value(1)) + }.get { foo in + print(foo, " is 1") + }.done { foo in + print(foo, " is 1") + }.done { foo in + print(foo, " is Void") + }.cancelContext + + //… + + context.cancel() + */ + func get(on: DispatchQueue? = conf.Q.return, flags: DispatchWorkItemFlags? = nil, _ body: @escaping (U.T) throws -> Void) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.return, flags: flags) + return get(on: dispatcher, body) + } + + /** + The provided closure is executed with cancellable promise result. + + This is like `get` but provides the Result of the CancellablePromise so you can inspect the value of the chain at this point without causing any side effects. + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter body: The closure that is executed with Result of CancellablePromise. + - Returns: A new cancellable promise that is resolved with the result that the handler is fed. For example: + + promise.tap{ print($0) }.then{ /*…*/ } + */ + func tap(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ body: @escaping(Result) -> Void) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return tap(on: dispatcher, body) + } +} + +public extension CancellableThenable where U.T: Sequence { + /** + `CancellablePromise<[U.T]>` => `U.T` -> `V` => `CancellablePromise<[V]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.mapValues { integer in + integer * 2 + }.done { + // $0 => [2,4,6] + } + */ + func mapValues(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V]> { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return mapValues(on: dispatcher, transform) + } + + /** + `CancellablePromise<[U.T]>` => `U.T` -> `[V]` => `CancellablePromise<[V]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.flatMapValues { integer in + [integer, integer] + }.done { + // $0 => [1,1,2,2,3,3] + } + */ + func flatMapValues(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.Iterator.Element]> { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return flatMapValues(on: dispatcher, transform) + } + + /** + `CancellablePromise<[U.T]>` => `U.T` -> `V?` => `CancellablePromise<[V]>` + + firstly { + cancellize(Promise.value(["1","2","a","3"])) + }.compactMapValues { + Int($0) + }.done { + // $0 => [1,2,3] + } + */ + func compactMapValues(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping(U.T.Iterator.Element) throws -> V?) -> CancellablePromise<[V]> { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return compactMapValues(on: dispatcher, transform) + } + + /** + `CancellablePromise<[U.T]>` => `U.T` -> `CancellablePromise` => `CancellablePromise<[V]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.thenMap { integer in + cancellize(Promise.value(integer * 2)) + }.done { + // $0 => [2,4,6] + } + */ + func thenMap(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.U.T]> { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return thenMap(on: dispatcher, transform) + } + + /** + `CancellablePromise<[U.T]>` => `U.T` -> `Promise` => `CancellablePromise<[V]>` + + firstly { + Promise.value([1,2,3]) + }.cancellize().thenMap { integer in + .value(integer * 2) + }.done { + // $0 => [2,4,6] + } + */ + func thenMap(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.T]> { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return thenMap(on: dispatcher, transform) + } + + /** + `CancellablePromise<[T]>` => `T` -> `CancellablePromise<[U]>` => `CancellablePromise<[U]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.thenFlatMap { integer in + cancellize(Promise.value([integer, integer])) + }.done { + // $0 => [1,1,2,2,3,3] + } + */ + func thenFlatMap(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.U.T.Iterator.Element]> where V.U.T: Sequence { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return thenFlatMap(on: dispatcher, transform) + } + + /** + `CancellablePromise<[T]>` => `T` -> `Promise<[U]>` => `CancellablePromise<[U]>` + + firstly { + Promise.value([1,2,3]) + }.cancellize().thenFlatMap { integer in + .value([integer, integer]) + }.done { + // $0 => [1,1,2,2,3,3] + } + */ + func thenFlatMap(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ transform: @escaping(U.T.Iterator.Element) throws -> V) -> CancellablePromise<[V.T.Iterator.Element]> where V.T: Sequence { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return thenFlatMap(on: dispatcher, transform) + } + + /** + `CancellablePromise<[T]>` => `T` -> Bool => `CancellablePromise<[U]>` + + firstly { + cancellize(Promise.value([1,2,3])) + }.filterValues { + $0 > 1 + }.done { + // $0 => [2,3] + } + */ + func filterValues(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, _ isIncluded: @escaping (U.T.Iterator.Element) -> Bool) -> CancellablePromise<[U.T.Iterator.Element]> { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return filterValues(on: dispatcher, isIncluded) + } +} + +public extension CancellableThenable where U.T: Collection { + func firstValue(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, where test: @escaping (U.T.Iterator.Element) -> Bool) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return firstValue(on: dispatcher, where: test) + } +} + +public extension CancellableThenable where U.T: Sequence, U.T.Iterator.Element: Comparable { + /// - Returns: a cancellable promise fulfilled with the sorted values of this `Sequence`. + func sortedValues(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil) -> CancellablePromise<[U.T.Iterator.Element]> { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return sortedValues(on: dispatcher) + } +} + +public extension CancellableCatchMixin { + /** + The provided closure executes when this cancellable promise rejects. + + Rejecting a promise cascades: rejecting all subsequent promises (unless + recover is invoked) thus you will typically place your catch at the end + of a chain. Often utility promises will not have a catch, instead + delegating the error handling to the caller. + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter policy: The default policy does not execute your handler for cancellation errors. + - Parameter body: The handler to execute if this promise is rejected. + - Returns: A promise finalizer. + - SeeAlso: [Cancellation](https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#cancellation) + */ + @discardableResult + func `catch`(on: DispatchQueue? = conf.Q.return, flags: DispatchWorkItemFlags? = nil, policy: CatchPolicy = conf.catchPolicy, _ body: @escaping(Error) -> Void) -> CancellableFinalizer { + let dispatcher = selectDispatcher(given: on, configured: conf.D.return, flags: flags) + return `catch`(on: dispatcher, policy: policy, body) + } + + /** + The provided closure executes when this cancellable promise rejects. + + Unlike `catch`, `recover` continues the chain. + Use `recover` in circumstances where recovering the chain from certain errors is a possibility. For example: + + let context = firstly { + CLLocationManager.requestLocation() + }.recover { error in + guard error == CLError.unknownLocation else { throw error } + return .value(CLLocation.chicago) + }.cancelContext + + //… + + context.cancel() + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter policy: The default policy does not execute your handler for cancellation errors. + - Parameter body: The handler to execute if this promise is rejected. + - SeeAlso: [Cancellation](https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#cancellation) + */ + func recover(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, policy: CatchPolicy = conf.catchPolicy, _ body: @escaping(Error) throws -> V) -> CancellablePromise where V.U.T == C.T { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return recover(on: dispatcher, policy: policy, body) + } + + /** + The provided closure executes when this cancellable promise rejects. + + Unlike `catch`, `recover` continues the chain. + Use `recover` in circumstances where recovering the chain from certain errors is a possibility. For example: + + let context = firstly { + CLLocationManager.requestLocation() + }.cancellize().recover { error in + guard error == CLError.unknownLocation else { throw error } + return .value(CLLocation.chicago) + }.cancelContext + + //… + + context.cancel() + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter policy: The default policy does not execute your handler for cancellation errors. + - Parameter body: The handler to execute if this promise is rejected. + - SeeAlso: [Cancellation](https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#cancellation) + - Note: Methods with the `cancellable` prefix create a new CancellablePromise, and those without the `cancellable` prefix accept an existing CancellablePromise. + */ + func recover(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, policy: CatchPolicy = conf.catchPolicy, _ body: @escaping(Error) throws -> V) -> CancellablePromise where V.T == C.T { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return recover(on: dispatcher, body) + } + + /** + The provided closure executes when this cancellable promise resolves, whether it rejects or not. + + let context = firstly { + UIApplication.shared.networkActivityIndicatorVisible = true + //… returns a cancellable promise + }.done { + //… + }.ensure { + UIApplication.shared.networkActivityIndicatorVisible = false + }.catch { + //… + }.cancelContext + + //… + + context.cancel() + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter body: The closure that executes when this promise resolves. + - Returns: A new promise, resolved with this promise’s resolution. + */ + func ensure(on: DispatchQueue? = conf.Q.return, flags: DispatchWorkItemFlags? = nil, _ body: @escaping () -> Void) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.return, flags: flags) + return ensure(on: dispatcher, body) + } + + /** + The provided closure executes when this cancellable promise resolves, whether it rejects or not. + The chain waits on the returned `CancellablePromise`. + + let context = firstly { + setup() // returns a cancellable promise + }.done { + //… + }.ensureThen { + teardown() // -> CancellablePromise + }.catch { + //… + }.cancelContext + + //… + + context.cancel() + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter body: The closure that executes when this promise resolves. + - Returns: A new cancellable promise, resolved with this promise’s resolution. + */ + func ensureThen(on: DispatchQueue? = conf.Q.return, flags: DispatchWorkItemFlags? = nil, _ body: @escaping () -> CancellablePromise) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.return, flags: flags) + return ensureThen(on: dispatcher, body) + } +} + +public extension CancellableFinalizer { + /// `finally` is the same as `ensure`, but it is not chainable + @discardableResult + func finally(on: DispatchQueue? = .pmkDefault, flags: DispatchWorkItemFlags? = nil, _ body: @escaping () -> Void) -> CancelContext { + let dispatcher = selectDispatcher(given: on, configured: conf.D.return, flags: flags) + return finally(on: dispatcher, body) + } +} + +public extension CancellableCatchMixin where C.T == Void { + /** + The provided closure executes when this cancellable promise rejects. + + This variant of `recover` ensures that no error is thrown from the handler and allows specifying a catch policy. + + - Parameter on: The queue to which the provided closure dispatches. + - Parameter policy: The default policy does not execute your handler for cancellation errors. + - Parameter body: The handler to execute if this promise is rejected. + - SeeAlso: [Cancellation](https://github.com/mxcl/PromiseKit/blob/master/Documentation/CommonPatterns.md#cancellation) + */ + func recover(on: DispatchQueue? = conf.Q.map, flags: DispatchWorkItemFlags? = nil, policy: CatchPolicy = conf.catchPolicy, _ body: @escaping(Error) throws -> Void) -> CancellablePromise { + let dispatcher = selectDispatcher(given: on, configured: conf.D.map, flags: flags) + return recover(on: dispatcher, policy: policy, body) + } +} diff --git a/Sources/Error.swift b/Sources/Error.swift index 7229e6f49..1b8238a69 100644 --- a/Sources/Error.swift +++ b/Sources/Error.swift @@ -18,7 +18,10 @@ public enum PMKError: Error { /// The operation was cancelled case cancelled - + + /// The operation timed out and was cancelled + case timedOut + /// `nil` was returned from `flatMap` @available(*, deprecated, message: "See: `compactMap`") case flatMap(Any, Any.Type) @@ -49,6 +52,8 @@ extension PMKError: CustomDebugStringConvertible { return "Bad input was provided to a PromiseKit function" case .cancelled: return "The asynchronous sequence was cancelled" + case .timedOut: + return "The asynchronous sequence timed out" case .emptySequence: return "The first or last element was requested for an empty sequence" } @@ -76,6 +81,8 @@ extension Error { throw self } catch PMKError.cancelled { return true + } catch PMKError.timedOut { + return true } catch let error as CancellableError { return error.isCancelled } catch URLError.cancelled { diff --git a/Sources/Guarantee.swift b/Sources/Guarantee.swift index d007f8214..a33e87d85 100644 --- a/Sources/Guarantee.swift +++ b/Sources/Guarantee.swift @@ -23,6 +23,12 @@ public final class Guarantee: Thenable { body(box.seal) } + /// Returns a pending `Guarantee` that can be resolved with the provided closure’s parameter. + public convenience init(cancellable: Cancellable, resolver body: (@escaping(T) -> Void) -> Void) { + self.init(resolver: body) + setCancellable(cancellable) + } + /// - See: `Thenable.pipe` public func pipe(to: @escaping(Result) -> Void) { pipe{ to(.success($0)) } @@ -55,10 +61,13 @@ public final class Guarantee: Thenable { } final private class Box: EmptyBox { + var cancelled = false deinit { switch inspect() { case .pending: - PromiseKit.conf.logHandler(.pendingGuaranteeDeallocated) + if !cancelled { + PromiseKit.conf.logHandler(.pendingGuaranteeDeallocated) + } case .resolved: break } @@ -73,6 +82,35 @@ public final class Guarantee: Thenable { public class func pending() -> (guarantee: Guarantee, resolve: (T) -> Void) { return { ($0, $0.box.seal) }(Guarantee(.pending)) } + + var cancellable: Cancellable? + + public func setCancellable(_ cancellable: Cancellable) { + if let gb = (box as? Guarantee.Box) { + self.cancellable = CancellableWrapper(box: gb, cancellable: cancellable) + } else { + self.cancellable = cancellable + } + } + + final private class CancellableWrapper: Cancellable { + let box: Guarantee.Box + let cancellable: Cancellable + + init(box: Guarantee.Box, cancellable: Cancellable) { + self.box = box + self.cancellable = cancellable + } + + func cancel() { + box.cancelled = true + cancellable.cancel() + } + + var isCancelled: Bool { + return cancellable.isCancelled + } + } } public extension Guarantee { diff --git a/Sources/Promise.swift b/Sources/Promise.swift index 792f2d4da..74e7d1db6 100644 --- a/Sources/Promise.swift +++ b/Sources/Promise.swift @@ -64,6 +64,19 @@ public final class Promise: Thenable, CatchMixin { } } + /// Initialize a new promise that can be resolved with the provided `Resolver`. + public init(cancellable: Cancellable, resolver body: (Resolver) throws -> Void) { + box = EmptyBox() + let resolver = Resolver(box) + self.cancellable = cancellable + self.rejectIfCancelled = resolver.reject + do { + try body(resolver) + } catch { + resolver.reject(error) + } + } + /// - Returns: a tuple of a new pending promise and its `Resolver`. public class func pending() -> (promise: Promise, resolver: Resolver) { return { ($0, Resolver($0.box)) }(Promise(.pending)) @@ -99,6 +112,14 @@ public final class Promise: Thenable, CatchMixin { init(_: PMKUnambiguousInitializer) { box = EmptyBox() } + + var cancellable: Cancellable? + var rejectIfCancelled: ((Error) -> Void)? + + public func setCancellable(_ cancellable: Cancellable?, reject: ((Error) -> Void)? = nil) { + self.cancellable = cancellable + rejectIfCancelled = reject + } } public extension Promise { diff --git a/Sources/Thenable.swift b/Sources/Thenable.swift index 055d956f9..98206116f 100644 --- a/Sources/Thenable.swift +++ b/Sources/Thenable.swift @@ -274,6 +274,18 @@ public extension Thenable { } } +public extension Thenable { + /** + Converts a Promise or Guarantee into a promise that can be cancelled. + - Parameter thenable: The Thenable (Promise or Guarantee) to be made cancellable. + - Returns: A CancellablePromise that is a cancellable variant of the given Promise or Guarantee. + */ + func cancellize(cancelContext: CancelContext? = nil) -> CancellablePromise { + return CancellablePromise(self, cancelContext: cancelContext) + } +} + + public extension Thenable where T: Sequence { /** `Promise<[T]>` => `T` -> `U` => `Promise<[U]>` diff --git a/Sources/after.swift b/Sources/after.swift index ca31911fc..eadb0aa6f 100644 --- a/Sources/after.swift +++ b/Sources/after.swift @@ -1,17 +1,25 @@ import struct Foundation.TimeInterval import Dispatch + +/// Extend DispatchWorkItem to be cancellable +extension DispatchWorkItem: Cancellable { } + /** after(seconds: 1.5).then { //… } - Returns: A guarantee that resolves after the specified duration. +- Note: cancelling this guarantee will cancel the underlying timer task +- SeeAlso: [Cancellation](http://promisekit.org/docs/) */ public func after(seconds: TimeInterval) -> Guarantee { let (rg, seal) = Guarantee.pending() let when = DispatchTime.now() + seconds - q.asyncAfter(deadline: when, execute: { seal(()) }) + let task = DispatchWorkItem { seal(()) } + rg.setCancellable(task) + q.asyncAfter(deadline: when, execute: task) return rg } @@ -21,11 +29,15 @@ public func after(seconds: TimeInterval) -> Guarantee { } - Returns: A guarantee that resolves after the specified duration. + - Note: cancelling this guarantee will cancel the underlying timer task + - SeeAlso: [Cancellation](http://promisekit.org/docs/) */ public func after(_ interval: DispatchTimeInterval) -> Guarantee { let (rg, seal) = Guarantee.pending() let when = DispatchTime.now() + interval - q.asyncAfter(deadline: when, execute: { seal(()) }) + let task = DispatchWorkItem { seal(()) } + rg.setCancellable(task) + q.asyncAfter(deadline: when, execute: task) return rg } diff --git a/Sources/firstly.swift b/Sources/firstly.swift index a5b477da1..e5db90f50 100644 --- a/Sources/firstly.swift +++ b/Sources/firstly.swift @@ -37,3 +37,53 @@ public func firstly(execute body: () throws -> U) -> Promise { public func firstly(execute body: () -> Guarantee) -> Guarantee { return body() } + +//////////////////////////////////////////////////////////// Cancellation + +/** + `firstly` for cancellable promises. + + Compare: + + let context = URLSession.shared.dataTask(url: url1).cancellize().then { + URLSession.shared.dataTask(url: url2) + }.then { + URLSession.shared.dataTask(url: url3) + }.cancelContext + + // … + + context.cancel() + + With: + + let context = firstly { + URLSession.shared.dataTask(url: url1) + }.cancellize().then { + URLSession.shared.dataTask(url: url2) + }.then { + URLSession.shared.dataTask(url: url3) + }.cancelContext + + // … + + context.cancel() + + - Note: the block you pass excecutes immediately on the current thread/queue. + - See: firstly(execute: () -> Thenable) +*/ +public func firstly(execute body: () throws -> V) -> CancellablePromise { + do { + let rv = try body() + let rp: CancellablePromise + if let promise = rv as? CancellablePromise { + rp = promise + } else { + rp = CancellablePromise(rv.thenable) + } + rp.appendCancelContext(from: rv) + return rp + } catch { + return CancellablePromise(error: error) + } +} diff --git a/Sources/hang.swift b/Sources/hang.swift index 4831a73cd..7dda20736 100644 --- a/Sources/hang.swift +++ b/Sources/hang.swift @@ -49,3 +49,14 @@ public func hang(_ promise: Promise) throws -> T { return value } } + +//////////////////////////////////////////////////////////// Cancellation + +/** + Runs the active run-loop until the provided promise resolves. + + Simply calls `hang` directly on the delegate promise, so the behavior is exactly the same with Promise and CancellablePromise. + */ +public func hang(_ promise: CancellablePromise) throws -> T { + return try hang(promise.promise) +} diff --git a/Sources/race.swift b/Sources/race.swift index 2b817de26..7b55a0178 100644 --- a/Sources/race.swift +++ b/Sources/race.swift @@ -1,3 +1,5 @@ +import struct Foundation.TimeInterval + @inline(__always) private func _race(_ thenables: [U]) -> Promise { let rp = Promise(.pending) @@ -55,3 +57,88 @@ public func race(_ guarantees: Guarantee...) -> Guarantee { } return rg } + +//////////////////////////////////////////////////////////// Cancellation + +/** + Resolves with the first resolving cancellable promise from a set of cancellable promises. Calling + `cancel` on the race promise cancels all pending promises. All promises will be cancelled if any + promise rejects. + + let racePromise = race(promise1, promise2, promise3).then { winner in + //… + } + + //… + + racePromise.cancel() + + - Returns: A new promise that resolves when the first promise in the provided promises resolves. + - Warning: If any of the provided promises reject, the returned promise is rejected. + - Warning: aborts if the array is empty. +*/ +public func race(_ thenables: V...) -> CancellablePromise { + return race(thenables) +} + +/** + Resolves with the first resolving promise from a set of promises. Calling `cancel` on the race + promise cancels all pending promises. All promises will be cancelled if any promise rejects. + + let racePromise = race(promise1, promise2, promise3).then { winner in + //… + } + + //… + + racePromise.cancel() + + - Returns: A new promise that resolves when the first promise in the provided promises resolves. + - Warning: If any of the provided promises reject, the returned promise is rejected. + - Remark: Returns promise rejected with PMKError.badInput if empty array provided +*/ +public func race(_ thenables: [V]) -> CancellablePromise { + guard !thenables.isEmpty else { + return CancellablePromise(error: PMKError.badInput) + } + + let cancelThenables: (Result) -> Void = { result in + if case .failure = result { + for t in thenables { + if !t.cancelAttempted { + t.cancel() + } + } + } + } + + let promise = CancellablePromise(race(asThenables(thenables))) + for t in thenables { + t.thenable.pipe(to: cancelThenables) + promise.appendCancelContext(from: t) + } + return promise +} + +/** + Returns a promise that can be used to set a timeout for `race`. + + let promise1, promise2: Promise + race(promise1, promise2, timeout(seconds: 1.0)).done { winner in + //… + }.catch(policy: .allErrors) { + // Rejects with `PMKError.timedOut` if the timeout is exceeded before either `promise1` or + // `promise2` succeeds. + } + + When used with cancellable promises, all promises will be cancelled if the timeout is + exceeded or any promise rejects: + + let promise1, promise2: CancellablePromise + race(promise1, promise2, cancellize(timeout(seconds: 1.0))).done { winner in + //… + } + */ +public func timeout(seconds: TimeInterval) -> Promise { + return after(seconds: seconds).done { throw PMKError.timedOut } +} diff --git a/Sources/when.swift b/Sources/when.swift index ed654e0c5..6ab4f6b53 100644 --- a/Sources/when.swift +++ b/Sources/when.swift @@ -106,7 +106,7 @@ public func when Promise { - // ... + // … } let urls: [URL] = /*…*/ @@ -120,7 +120,7 @@ public func when...) -> Guarantee { public func when(guarantees: [Guarantee]) -> Guarantee { return when(fulfilled: guarantees).recover{ _ in }.asVoid() } + +//////////////////////////////////////////////////////////// Cancellation + +/** + Wait for all cancellable promises in a set to fulfill. + + For example: + + let p = when(fulfilled: promise1, promise2).then { results in + //… + }.catch { error in + switch error { + case URLError.notConnectedToInternet: + //… + case CLError.denied: + //… + } + } + + //… + + p.cancel() + + - Note: If *any* of the provided promises reject, the returned promise is immediately rejected with that error. + - Warning: In the event of rejection the other promises will continue to resolve and, as per any other promise, will either fulfill or reject. This is the right pattern for `getter` style asynchronous tasks, but often for `setter` tasks (eg. storing data on a server), you most likely will need to wait on all tasks and then act based on which have succeeded and which have failed, in such situations use `when(resolved:)`. + - Parameter promises: The promises upon which to wait before the returned promise resolves. + - Returns: A new promise that resolves when all the provided promises fulfill or one of the provided promises rejects. + - Note: `when` provides `NSProgress`. + - SeeAlso: `when(resolved:)` +*/ +public func when(fulfilled thenables: V...) -> CancellablePromise<[V.U.T]> { + let rp = CancellablePromise(when(fulfilled: asThenables(thenables))) + for t in thenables { + rp.appendCancelContext(from: t) + } + return rp +} + +public func when(fulfilled thenables: [V]) -> CancellablePromise<[V.U.T]> { + let rp = CancellablePromise(when(fulfilled: asThenables(thenables))) + for t in thenables { + rp.appendCancelContext(from: t) + } + return rp +} + +/// Wait for all cancellable promises in a set to fulfill. +public func when(fulfilled promises: V...) -> CancellablePromise where V.U.T == Void { + let rp = CancellablePromise(when(fulfilled: asThenables(promises))) + for p in promises { + rp.appendCancelContext(from: p) + } + return rp +} + +/// Wait for all cancellable promises in a set to fulfill. +public func when(fulfilled promises: [V]) -> CancellablePromise where V.U.T == Void { + let rp = CancellablePromise(when(fulfilled: asThenables(promises))) + for p in promises { + rp.appendCancelContext(from: p) + } + return rp +} + +/** + Wait for all cancellable promises in a set to fulfill. + + - Note: by convention the cancellable 'when' functions should not have a 'cancellable' prefix, however the prefix is necessary due to a compiler bug exemplified by the following: + + ```` + This works fine: + 1 func hi(_: String...) { } + 2 func hi(_: String, _: String) { } + 3 hi("hi", "there") + + This does not compile: + 1 func hi(_: String...) { } + 2 func hi(_: String, _: String) { } + 3 func hi(_: Int...) { } + 4 func hi(_: Int, _: Int) { } + 5 + 6 hi("hi", "there") // Ambiguous use of 'hi' (lines 1 & 2 are candidates) + 7 hi(1, 2) // Ambiguous use of 'hi' (lines 3 & 4 are candidates) + ```` + + - SeeAlso: `when(fulfilled:,_:)` +*/ +public func cancellableWhen(fulfilled pu: U, _ pv: V) -> CancellablePromise<(U.U.T, V.U.T)> { + return when(fulfilled: [pu.asVoid(), pv.asVoid()]).map(on: nil) { (pu.value!, pv.value!) } +} + +/// Wait for all cancellable promises in a set to fulfill. +/// - SeeAlso: `when(fulfilled:,_:)` +public func cancellableWhen(fulfilled pu: U, _ pv: V, _ pw: W) -> CancellablePromise<(U.U.T, V.U.T, W.U.T)> { + return when(fulfilled: [pu.asVoid(), pv.asVoid(), pw.asVoid()]).map(on: nil) { (pu.value!, pv.value!, pw.value!) } +} + +/// Wait for all cancellable promises in a set to fulfill. +/// - SeeAlso: `when(fulfilled:,_:)` +public func cancellableWhen(fulfilled pu: U, _ pv: V, _ pw: W, _ px: X) -> CancellablePromise<(U.U.T, V.U.T, W.U.T, X.U.T)> { + return when(fulfilled: [pu.asVoid(), pv.asVoid(), pw.asVoid(), px.asVoid()]).map(on: nil) { (pu.value!, pv.value!, pw.value!, px.value!) } +} + +/// Wait for all cancellable promises in a set to fulfill. +/// - SeeAlso: `when(fulfilled:,_:)` +public func cancellableWhen(fulfilled pu: U, _ pv: V, _ pw: W, _ px: X, _ py: Y) -> CancellablePromise<(U.U.T, V.U.T, W.U.T, X.U.T, Y.U.T)> { + return when(fulfilled: [pu.asVoid(), pv.asVoid(), pw.asVoid(), px.asVoid(), py.asVoid()]).map(on: nil) { (pu.value!, pv.value!, pw.value!, px.value!, py.value!) } +} + +/** + Generate cancellable promises at a limited rate and wait for all to fulfill. Call `cancel` on the returned promise to cancel all currently pending promises. + + For example: + + func downloadFile(url: URL) -> CancellablePromise { + // … + } + + let urls: [URL] = /*…*/ + let urlGenerator = urls.makeIterator() + + let generator = AnyIterator> { + guard url = urlGenerator.next() else { + return nil + } + return downloadFile(url) + } + + let promise = when(generator, concurrently: 3).done { datas in + // … + } + + // … + + promise.cancel() + + + No more than three downloads will occur simultaneously. + + - Note: The generator is called *serially* on a *background* queue. + - Warning: Refer to the warnings on `when(fulfilled:)` + - Parameter promiseGenerator: Generator of promises. + - Parameter cancel: Optional cancel context, overrides the default context. + - Returns: A new promise that resolves when all the provided promises fulfill or one of the provided promises rejects. + - SeeAlso: `when(resolved:)` + */ +public func when(fulfilled promiseIterator: It, concurrently: Int) -> CancellablePromise<[It.Element.U.T]> where It.Element: CancellableThenable { + guard concurrently > 0 else { + return CancellablePromise(error: PMKError.badInput) + } + + var pi = promiseIterator + var generatedPromises: [CancellablePromise] = [] + var rootPromise: CancellablePromise<[It.Element.U.T]>! + + let generator = AnyIterator> { + guard let promise = pi.next() as? CancellablePromise else { + return nil + } + if let root = rootPromise { + root.appendCancelContext(from: promise) + } else { + generatedPromises.append(promise) + } + return promise.promise + } + + rootPromise = CancellablePromise(when(fulfilled: generator, concurrently: concurrently)) + for p in generatedPromises { + rootPromise.appendCancelContext(from: p) + } + return rootPromise +} + +/** + Waits on all provided cancellable promises. + + `when(fulfilled:)` rejects as soon as one of the provided promises rejects. `when(resolved:)` waits on all provided promises and *never* rejects. When cancelled, all promises will attempt to be cancelled and those that are successfully cancelled will have a result of + PMKError.cancelled. + + let p = when(resolved: promise1, promise2, promise3, cancel: context).then { results in + for result in results where case .fulfilled(let value) { + //… + } + }.catch { error in + // invalid! Never rejects + } + + //… + + p.cancel() + + - Returns: A new promise that resolves once all the provided promises resolve. The array is ordered the same as the input, ie. the result order is *not* resolution order. + - Note: Any promises that error are implicitly consumed. + - Remark: Doesn't take CancellableThenable due to protocol associatedtype paradox +*/ +public func when(resolved promises: CancellablePromise...) -> CancellablePromise<[Result]> { + return when(resolved: promises) +} + +/// Waits on all provided cancellable promises. +/// - SeeAlso: `when(resolved:)` +public func when(resolved promises: [CancellablePromise]) -> CancellablePromise<[Result]> { + let rp = CancellablePromise(when(resolved: asPromises(promises))) + for p in promises { + rp.appendCancelContext(from: p) + } + return rp +} + +func asThenables(_ cancellableThenables: [V]) -> [V.U] { + var thenables: [V.U] = [] + for ct in cancellableThenables { + thenables.append(ct.thenable) + } + return thenables +} + +func asPromises(_ cancellablePromises: [CancellablePromise]) -> [Promise] { + var promises = [Promise]() + for cp in cancellablePromises { + promises.append(cp.promise) + } + return promises +} diff --git a/Tests/Cancel/AfterTests.swift b/Tests/Cancel/AfterTests.swift new file mode 100644 index 000000000..27460ea9b --- /dev/null +++ b/Tests/Cancel/AfterTests.swift @@ -0,0 +1,121 @@ +import Foundation +import XCTest +import PromiseKit + +extension XCTestExpectation { + open func fulfill(error: Error) { + fulfill() + } +} + +class AfterTests: XCTestCase { + func fail() { XCTFail() } + + func testZero() { + let ex2 = expectation(description: "") + let cc2 = after(seconds: 0).cancellize().done(fail).catch(policy: .allErrors, ex2.fulfill) + cc2.cancel() + waitForExpectations(timeout: 2, handler: nil) + + let ex3 = expectation(description: "") + let cc3 = after(.seconds(0)).cancellize().done(fail).catch(policy: .allErrors, ex3.fulfill) + cc3.cancel() + waitForExpectations(timeout: 2, handler: nil) + } + + func testNegative() { + let ex2 = expectation(description: "") + let cc2 = after(seconds: -1).cancellize().done(fail).catch(policy: .allErrors, ex2.fulfill) + cc2.cancel() + waitForExpectations(timeout: 2, handler: nil) + + let ex3 = expectation(description: "") + let cc3 = after(.seconds(-1)).cancellize().done(fail).catch(policy: .allErrors, ex3.fulfill) + cc3.cancel() + waitForExpectations(timeout: 2, handler: nil) + } + + func testPositive() { + let ex2 = expectation(description: "") + let cc2 = after(seconds: 1).cancellize().done(fail).catch(policy: .allErrors, ex2.fulfill) + cc2.cancel() + waitForExpectations(timeout: 2, handler: nil) + + let ex3 = expectation(description: "") + let cc3 = after(.seconds(1)).cancellize().done(fail).catch(policy: .allErrors, ex3.fulfill) + cc3.cancel() + waitForExpectations(timeout: 2, handler: nil) + } + + func testCancellableAfter() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + + // Test the normal 'after' function + let exComplete = expectation(description: "after completes") + let afterPromise = after(seconds: 0) + afterPromise.done { + exComplete.fulfill() + }.catch { error in + XCTFail("afterPromise failed with error: \(error)") + } + + let exCancelComplete = expectation(description: "after completes") + + // Test cancellable `after` to ensure it is fulfilled if not cancelled + let cancelIgnoreAfterPromise = after(seconds: 0).cancellize() + cancelIgnoreAfterPromise.done { + exCancelComplete.fulfill() + }.catch(policy: .allErrors) { error in + XCTFail("cancelIgnoreAfterPromise failed with error: \(error)") + } + + // Test cancellable `after` to ensure it is cancelled + let cancellableAfterPromise = after(seconds: 0).cancellize() + cancellableAfterPromise.done { + XCTFail("cancellableAfter not cancelled") + }.catch(policy: .allErrorsExceptCancellation) { error in + XCTFail("cancellableAfterPromise failed with error: \(error)") + }.cancel() + + // Test cancellable `after` to ensure it is cancelled and throws a `CancellableError` + let exCancel = expectation(description: "after cancels") + let cancellableAfterPromiseWithError = after(seconds: 0).cancellize() + cancellableAfterPromiseWithError.done { + XCTFail("cancellableAfterWithError not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exCancel.fulfill() : XCTFail("unexpected error \(error)") + }.cancel() + + wait(for: [exComplete, exCancelComplete, exCancel], timeout: 1) + } + + func testCancelForPromise_Done() { + let exComplete = expectation(description: "done is cancelled") + + let promise = CancellablePromise { seal in + seal.fulfill(()) + } + promise.done { _ in + XCTFail("done not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + } + + promise.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testCancelForGuarantee_Done() { + let exComplete = expectation(description: "done is cancelled") + + after(seconds: 0).cancellize().done { _ in + XCTFail("done not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } +} diff --git a/Tests/Cancel/CancelChain.swift b/Tests/Cancel/CancelChain.swift new file mode 100644 index 000000000..e2345637d --- /dev/null +++ b/Tests/Cancel/CancelChain.swift @@ -0,0 +1,246 @@ +import XCTest +import PromiseKit + +class CancelChain: XCTestCase { + // Using a distinct type for each promise so we can tell which promise is which when using trace messages inside Thenable + struct A { } + struct B { } + struct C { } + struct D { } + struct E { } + + struct Chain { + let pA: CancellablePromise + let pB: CancellablePromise + let pC: CancellablePromise + let pD: CancellablePromise + let pE: CancellablePromise + } + + func trace(_ message: String) { + // print(message) + } + + func cancelChainPromises() -> Chain { + let pA = CancellablePromise { seal in + self.trace("A IN") + after(seconds: 0.05).cancellize().done { + self.trace("A FULFILL") + seal.fulfill(A()) + }.catch(policy: .allErrors) { + self.trace("A ERR") + seal.reject($0) + } + } + + let pB = CancellablePromise { seal in + self.trace("B IN") + after(seconds: 0.1).cancellize().done { + self.trace("B FULFILL") + seal.fulfill(B()) + }.catch(policy: .allErrors) { + self.trace("B ERR") + seal.reject($0) + } + } + + let pC = CancellablePromise { seal in + self.trace("C IN") + after(seconds: 0.15).cancellize().done { + self.trace("C FULFILL") + seal.fulfill(C()) + }.catch(policy: .allErrors) { + self.trace("C ERR") + seal.reject($0) + } + } + + let pD = CancellablePromise { seal in + self.trace("D IN") + after(seconds: 0.2).cancellize().done { + self.trace("D FULFILL") + seal.fulfill(D()) + }.catch(policy: .allErrors) { + self.trace("D ERR") + seal.reject($0) + } + } + + let pE = CancellablePromise { seal in + self.trace("E IN") + after(seconds: 0.25).cancellize().done { + self.trace("E FULFILL") + seal.fulfill(E()) + }.catch(policy: .allErrors) { + self.trace("E ERR") + seal.reject($0) + } + } + + return Chain(pA: pA, pB: pB, pC: pC, pD: pD, pE: pE) + } + + struct exABCDE { + let a: XCTestExpectation? + let b: XCTestExpectation? + let c: XCTestExpectation? + let d: XCTestExpectation? + let e: XCTestExpectation? + let cancelled: XCTestExpectation? + + let cancelA: Bool + let cancelB: Bool + let cancelC: Bool + let cancelD: Bool + let cancelE: Bool + } + + func cancelChainSetup(ex: exABCDE) { + { + let c = cancelChainPromises() + + c.pA.then { (_: A) -> CancellablePromise in + self.trace("pA.then") + return firstly { () -> CancellablePromise in + self.trace("pB.firstly") + return c.pB + }.then { (_: B) -> CancellablePromise in + self.trace("pB.then") + return firstly { () -> CancellablePromise in + self.trace("pC.firstly") + if ex.cancelB { + self.trace("CANCEL") + c.pA.cancel() + } + ex.b?.fulfill() ?? XCTFail("pB.then") + return c.pC + }.then { (_: C) -> CancellablePromise in + ex.c?.fulfill() ?? XCTFail("pC.then") + if ex.cancelC { + self.trace("CANCEL") + c.pA.cancel() + } + self.trace("pC.then") + return c.pD + } + }.then { (_: D) -> CancellablePromise in + ex.d?.fulfill() ?? XCTFail("pD.done") + if ex.cancelD { + self.trace("CANCEL") + c.pA.cancel() + } + return c.pA // Intentional reuse of pA -- causes a loop that CancelContext must detect + } + }.then { (_: A) -> CancellablePromise in + self.trace("pA.then") + ex.a?.fulfill() ?? XCTFail("pA completed") + if ex.cancelA { + self.trace("CANCEL") + c.pA.cancel() + } + return c.pE + }.done { _ in + ex.e?.fulfill() ?? XCTFail("pE completed") + if ex.cancelE { + self.trace("CANCEL") + c.pA.cancel() + } + self.trace("pE.done") + }.catch(policy: .allErrors) { + self.trace("Error: \($0)") + $0.isCancelled ? ex.cancelled?.fulfill() : XCTFail("Error: \($0)") + } + + self.trace("SETUP COMPLETE") + +#if swift(>=4.1) + let expectations = [ex.a, ex.b, ex.c, ex.d, ex.e, ex.cancelled].compactMap { $0 } +#else + let expectations = [ex.a, ex.b, ex.c, ex.d, ex.e, ex.cancelled].flatMap { $0 } +#endif + wait(for: expectations, timeout: 1) + + XCTAssert(c.pA.cancelContext.cancelAttempted) + XCTAssert(ex.a == nil || isFulfilled(c.pB) || c.pB.cancelContext.cancelAttempted) + XCTAssert(ex.b == nil || isFulfilled(c.pC) || c.pC.cancelContext.cancelAttempted) + XCTAssert(ex.c == nil || isFulfilled(c.pD) || c.pD.cancelContext.cancelAttempted) + XCTAssert(ex.d == nil || isFulfilled(c.pE) || c.pE.cancelContext.cancelAttempted) + }() + + self.trace("DONE") + + return + } + + func isFulfilled(_ p: CancellablePromise) -> Bool { + if let result = p.promise.result { + if case .success = result { + return true + } else { + return false + } + } else { + return false + } + } + + func testCancelChainPB() { + let ex = exABCDE(a: nil, + b: expectation(description: "pB completed"), + c: nil, + d: nil, + e: nil, + cancelled: expectation(description: "cancelled"), + cancelA: false, + cancelB: true, + cancelC: false, + cancelD: false, + cancelE: false) + cancelChainSetup(ex: ex) + } + + func testCancelChainPC() { + let ex = exABCDE(a: nil, + b: expectation(description: "pB completed"), + c: expectation(description: "pC completed"), + d: nil, + e: nil, + cancelled: expectation(description: "cancelled"), + cancelA: false, + cancelB: false, + cancelC: true, + cancelD: false, + cancelE: false) + cancelChainSetup(ex: ex) + } + + func testCancelChainPAD() { + let ex = exABCDE(a: expectation(description: "pA completed"), + b: expectation(description: "pB completed"), + c: expectation(description: "pC completed"), + d: expectation(description: "pD completed"), + e: nil, + cancelled: expectation(description: "cancelled"), + cancelA: true, + cancelB: false, + cancelC: false, + cancelD: false, + cancelE: false) + cancelChainSetup(ex: ex) + } + + func testCancelChainSuccess() { + let ex = exABCDE(a: expectation(description: "pA completed"), + b: expectation(description: "pB completed"), + c: expectation(description: "pC completed"), + d: expectation(description: "pD completed"), + e: expectation(description: "pE completed"), + cancelled: nil, + cancelA: false, + cancelB: false, + cancelC: false, + cancelD: false, + cancelE: true) + cancelChainSetup(ex: ex) + } +} diff --git a/Tests/Cancel/CancellableErrorTests.swift b/Tests/Cancel/CancellableErrorTests.swift new file mode 100644 index 000000000..612925b74 --- /dev/null +++ b/Tests/Cancel/CancellableErrorTests.swift @@ -0,0 +1,136 @@ +import Foundation +import PromiseKit +import XCTest + +class CancellationTests: XCTestCase { + func testCancellation() { + let ex1 = expectation(description: "") + + let p = after(seconds: 0).cancellize().done { _ in + XCTFail() + } + p.catch { _ in + XCTFail() + } + p.catch(policy: .allErrors) { + XCTAssertTrue($0.isCancelled) + ex1.fulfill() + } + + p.cancel(with: LocalError.cancel) + + waitForExpectations(timeout: 1) + } + + func testThrowCancellableErrorThatIsNotCancelled() { + let expect = expectation(description: "") + + let cc = after(seconds: 0).cancellize().done { + XCTFail() + }.catch { + XCTAssertFalse($0.isCancelled) + expect.fulfill() + } + + cc.cancel(with: LocalError.notCancel) + + waitForExpectations(timeout: 1) + } + + func testRecoverWithCancellation() { + let ex1 = expectation(description: "") + let ex2 = expectation(description: "") + + let p = after(seconds: 0).cancellize().done { _ in + XCTFail() + }.recover(policy: .allErrors) { err -> CancellablePromise in + ex1.fulfill() + XCTAssertTrue(err.isCancelled) + throw err + }.done { _ in + XCTFail() + } + p.catch { _ in + XCTFail() + } + p.catch(policy: .allErrors) { + XCTAssertTrue($0.isCancelled) + ex2.fulfill() + } + + p.cancel(with: CocoaError.cancelled) + + waitForExpectations(timeout: 1) + } + + func testFoundationBridging1() { + let ex = expectation(description: "") + + let p = after(seconds: 0).cancellize().done { _ in + XCTFail() + } + p.catch { _ in + XCTFail() + } + p.catch(policy: .allErrors) { + XCTAssertTrue($0.isCancelled) + ex.fulfill() + } + + p.cancel(with: CocoaError.cancelled) + + waitForExpectations(timeout: 1) + } + + func testFoundationBridging2() { + let ex = expectation(description: "") + + let p = CancellablePromise().done { + XCTFail() + } + p.catch { _ in + XCTFail() + } + p.catch(policy: .allErrors) { + XCTAssertTrue($0.isCancelled) + ex.fulfill() + } + + p.cancel(with: URLError.cancelled) + + waitForExpectations(timeout: 1) + } + +#if swift(>=3.2) + func testIsCancelled() { + XCTAssertTrue(PMKError.cancelled.isCancelled) + XCTAssertTrue(URLError.cancelled.isCancelled) + XCTAssertTrue(CocoaError.cancelled.isCancelled) + XCTAssertFalse(CocoaError(_nsError: NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.coderInvalidValue.rawValue)).isCancelled) + } +#endif +} + +private enum LocalError: CancellableError { + case notCancel + case cancel + + var isCancelled: Bool { + switch self { + case .notCancel: return false + case .cancel: return true + } + } +} + +private extension URLError { + static var cancelled: URLError { + return .init(_nsError: NSError(domain: NSURLErrorDomain, code: URLError.Code.cancelled.rawValue)) + } +} + +private extension CocoaError { + static var cancelled: CocoaError { + return .init(_nsError: NSError(domain: NSCocoaErrorDomain, code: CocoaError.Code.userCancelled.rawValue)) + } +} diff --git a/Tests/Cancel/CancellablePromiseTests.swift b/Tests/Cancel/CancellablePromiseTests.swift new file mode 100644 index 000000000..7485a3c50 --- /dev/null +++ b/Tests/Cancel/CancellablePromiseTests.swift @@ -0,0 +1,205 @@ +import Foundation +import PromiseKit +import XCTest + +class CancellablePromiseTests: XCTestCase { + func login() -> Promise { + return Promise.value(1) + } + + func fetch(avatar: Int) -> CancellablePromise { + return Promise.value(avatar + 2).cancellize() + } + + func testCancellablePromiseEmbeddedInStandardPromiseChain() { + let ex = expectation(description: "") + var imageView: Int? + let promise = firstly { /// <-- ERROR: Ambiguous reference to member 'firstly(execute:)' + /* The 'cancellize' method initiates a cancellable promise chain by + returning a 'CancellablePromise'. */ + login().cancellize() /// CHANGE TO: "login().cancellize()" + }.then { creds in + self.fetch(avatar: creds) + }.done { image in + imageView = image + XCTAssert(imageView == 3) + XCTFail() + }.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + ex.fulfill() + } else { + XCTFail() + } + } + + // … + + promise.cancel() + + waitForExpectations(timeout: 1) + } + + func testReturnTypeForAMultiLineClosureIsNotExplicitlyStated() { + let ex = expectation(description: "") + var imageView: Int? + firstly { + login() + }.cancellize().then { creds -> CancellablePromise in + let f = self.fetch(avatar: creds) + return f + }.done { image in + imageView = image + XCTAssert(imageView == 3) + ex.fulfill() + }.catch(policy: .allErrors) { error in + XCTFail() + } + + waitForExpectations(timeout: 1) + } + + func testTryingToCancelAStandardPromiseChain() { + let ex = expectation(description: "") + var imageView: Int? + let promise = firstly { + login() + }.cancellize().then { creds in + self.fetch(avatar: creds) + }.done { image in + imageView = image + XCTAssert(imageView == 3) + XCTFail() + }.catch(policy: .allErrors) { error in + if error.isCancelled { + // the chain has been cancelled! + ex.fulfill() + } else { + XCTFail() + } + } + + // … + + promise.cancel() /// <-- ERROR: Value of type 'PMKFinalizer' has no member 'cancel' + + waitForExpectations(timeout: 1) + } + + func testCancel() { + let ex = expectation(description: "") + let p = CancellablePromise.pending() + p.promise.then { (val: Int) -> Promise in + Promise.value("hi") + }.done { _ in + XCTFail() + ex.fulfill() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + p.resolver.fulfill(3) + p.promise.cancel() + + wait(for: [ex], timeout: 1) + } + + func testFirstly() { + let ex = expectation(description: "") + firstly { + Promise.value(3) + }.cancellize().then { (_: Int) -> Promise in + XCTFail() + return Promise.value("hi") + }.done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testFirstlyWithPromise() { + let ex = expectation(description: "") + firstly { + return Promise.value(3) + }.cancellize().then { (_: Int) -> Promise in + XCTFail() + return Promise.value("hi") + }.done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("\($0)") + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testThenMapSuccess() { + let ex = expectation(description: "") + firstly { + Promise.value([1,2,3]) + }.cancellize().thenMap { (integer: Int) -> Promise in + return Promise.value(integer * 2) + }.done { _ in + ex.fulfill() + // $0 => [2,4,6] + }.catch(policy: .allErrors) { _ in + XCTFail() + } + waitForExpectations(timeout: 1) + } + + func testThenMapCancel() { + let ex = expectation(description: "") + firstly { + Promise.value([1,2,3]) + }.cancellize().thenMap { (integer: Int) -> Promise in + XCTFail() + return Promise.value(integer * 2) + }.done { _ in + XCTFail() + // $0 => [2,4,6] + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1) + } + + func testChain() { + let ex = expectation(description: "") + firstly { + Promise.value(1) + }.cancellize().then { (integer: Int) -> Promise in + XCTFail() + return Promise.value(integer * 2) + }.done { _ in + // $0 => [2,4,6] + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1) + } + + func testBridge() { + let ex1 = expectation(description: "") + let ex2 = expectation(description: "") + + let (promise, seal) = Promise.pending() + DispatchQueue.global(qos: .default).asyncAfter(deadline: DispatchTime.now() + 0.2) { seal.fulfill(()) } + + CancellablePromise(promise).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex2.fulfill() : XCTFail() + }.cancel() + + promise.done { _ in + ex1.fulfill() + }.catch(policy: .allErrors) { _ in + XCTFail() + } + + waitForExpectations(timeout: 1) + } +} diff --git a/Tests/Cancel/CatchableTests.swift b/Tests/Cancel/CatchableTests.swift new file mode 100644 index 000000000..fc4520986 --- /dev/null +++ b/Tests/Cancel/CatchableTests.swift @@ -0,0 +1,395 @@ +import PromiseKit +import Dispatch +import XCTest + +class CatchableTests: XCTestCase { + + func testFinally() { + let finallyQueue = DispatchQueue(label: "\(#file):\(#line)", attributes: .concurrent) + + func helper(error: Error, on queue: DispatchQueue = .main, flags: DispatchWorkItemFlags? = nil) { + let ex = (expectation(description: ""), expectation(description: "")) + var x = 0 + let p = after(seconds: 0.01).cancellize().catch(policy: .allErrors) { _ in + XCTAssertEqual(x, 0) + x += 1 + ex.0.fulfill() + }.finally(on: queue, flags: flags) { + if let flags = flags, flags.contains(.barrier) { + dispatchPrecondition(condition: .onQueueAsBarrier(queue)) + } else { + dispatchPrecondition(condition: .onQueue(queue)) + } + XCTAssertEqual(x, 1) + x += 1 + ex.1.fulfill() + } + + p.cancel(with: error) + + wait(for: [ex.0, ex.1], timeout: 10) + } + + helper(error: Error.dummy) + helper(error: Error.cancelled) + helper(error: Error.dummy, on: finallyQueue) + helper(error: Error.dummy, on: finallyQueue, flags: .barrier) + } + + func testCauterize() { + let ex = expectation(description: "") + let p = after(seconds: 0.01).cancellize() + + // cannot test specifically that this outputs to console, + // but code-coverage will note that the line is run + p.cauterize() + + p.catch { _ in + ex.fulfill() + } + + p.cancel(with: Error.dummy) + + wait(for: [ex], timeout: 1) + } +} + +/// `Promise.recover` +extension CatchableTests { + func test__void_specialized_full_recover() { + + func helper(policy: CatchPolicy, error: Swift.Error, line: UInt = #line) { + let ex = expectation(description: "error caught") + CancellablePromise(error: error).recover { _ in }.done { _ in XCTFail() }.catch(policy: .allErrors, ex.fulfill).cancel() + wait(for: [ex], timeout: 1) + } + + helper(policy: .allErrorsExceptCancellation, error: Error.dummy) + helper(policy: .allErrors, error: Error.dummy) + helper(policy: .allErrorsExceptCancellation, error: Error.cancelled) + helper(policy: .allErrors, error: Error.cancelled) + + let ex2 = expectation(description: "cancel caught") + let d2 = CancellablePromise(error: Error.cancelled).recover(policy: .allErrors) { _ in }.done(ex2.fulfill) + d2.cancel() + wait(for: [ex2], timeout: 1) + } + + func test__void_specialized_full_recover__fulfilled_path() { + let ex = expectation(description: "") + CancellablePromise().recover { _ in + XCTFail() + }.done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + wait(for: [ex], timeout: 1) + + let ex2 = expectation(description: "") + let promise = CancellablePromise() + promise.cancel() + promise.recover(policy: .allErrors) { _ in }.done(ex2.fulfill).catch(policy: .allErrors) { _ in XCTFail() } + wait(for: [ex2], timeout: 1) + } + + func test__void_specialized_conditional_recover() { + func helperDone(policy: CatchPolicy, error: Swift.Error, line: UInt = #line) { + let ex = expectation(description: "") + var x = 0 + let promise = CancellablePromise(error: error).recover(policy: policy) { (err: Swift.Error) throws -> Void in + guard x < 1 else { throw err } + x += 1 + }.done(ex.fulfill).catch(policy: .allErrors) { _ in + XCTFail() + } + promise.cancel() + wait(for: [ex], timeout: 1) + } + + func helperCatch(policy: CatchPolicy, error: Swift.Error, line: UInt = #line) { + let ex = expectation(description: "") + var x = 0 + let promise = CancellablePromise(error: error).recover(policy: policy) { (err: Swift.Error) throws -> Void in + guard x < 1 else { throw err } + x += 1 + }.done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + promise.cancel() + wait(for: [ex], timeout: 1) + } + + for error in [Error.dummy as Swift.Error, Error.cancelled] { + helperDone(policy: .allErrors, error: error) + } + helperCatch(policy: .allErrorsExceptCancellation, error: Error.dummy) + } + + func test__void_specialized_conditional_recover__no_recover() { + + func helper(policy: CatchPolicy, error: Error, line: UInt = #line) { + let ex = expectation(description: "") + CancellablePromise(error: error).recover(policy: .allErrorsExceptCancellation) { err in + throw err + }.catch(policy: .allErrors) { _ in + ex.fulfill() + }.cancel() + wait(for: [ex], timeout: 1) + } + + for error in [Error.dummy, Error.cancelled] { + helper(policy: .allErrors, error: error) + } + helper(policy: .allErrorsExceptCancellation, error: Error.dummy) + } + + func test__void_specialized_conditional_recover__ignores_cancellation_but_fed_cancellation() { + let ex = expectation(description: "") + CancellablePromise(error: Error.cancelled).recover(policy: .allErrorsExceptCancellation) { _ in + XCTFail() + }.catch(policy: .allErrors) { + XCTAssertEqual(Error.cancelled, $0 as? Error) + ex.fulfill() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func test__void_specialized_conditional_recover__fulfilled_path() { + let ex = expectation(description: "") + let p = CancellablePromise().recover { _ in + XCTFail() + }.catch { _ in + XCTFail() // this `catch` to ensure we are calling the `recover` variant we think we are + }.finally { + ex.fulfill() + } + p.cancel() + wait(for: [ex], timeout: 1) + } +} + +/// `Promise.recover` +extension CatchableTests { + func test__full_recover() { + func helper(error: Swift.Error) { + let ex = expectation(description: "") + CancellablePromise(error: error).recover { _ in + return Promise.value(2).cancellize() + }.done { _ in + XCTFail() + }.catch(policy: .allErrors, ex.fulfill).cancel() + wait(for: [ex], timeout: 1) + } + + helper(error: Error.dummy) + helper(error: Error.cancelled) + } + + func test__full_recover__fulfilled_path() { + let ex = expectation(description: "") + Promise.value(1).cancellize().recover { _ -> CancellablePromise in + XCTFail() + return Promise.value(2).cancellize() + }.done { _ in + XCTFail() + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func test__conditional_recover() { + func helper(policy: CatchPolicy, error: Swift.Error, line: UInt = #line) { + let ex = expectation(description: "\(policy) \(error) \(line)") + var x = 0 + CancellablePromise(error: error).recover(policy: policy) { (err: Swift.Error) throws -> CancellablePromise in + guard x < 1 else { + throw err + } + x += 1 + return Promise.value(x).cancellize() + }.done { _ in + ex.fulfill() + }.catch(policy: .allErrors) { error in + if policy == .allErrorsExceptCancellation { + error.isCancelled ? ex.fulfill() : XCTFail() + } else { + XCTFail() + } + }.cancel() + wait(for: [ex], timeout: 1) + } + + for error in [Error.dummy as Swift.Error, Error.cancelled] { + helper(policy: .allErrors, error: error) + } + helper(policy: .allErrorsExceptCancellation, error: Error.dummy) + } + + func test__conditional_recover__no_recover() { + + func helper(policy: CatchPolicy, error: Error, line: UInt = #line) { + let ex = expectation(description: "\(policy) \(error) \(line)") + CancellablePromise(error: error).recover(policy: policy) { err -> CancellablePromise in + throw err + }.catch(policy: .allErrors) { + if !(($0 as? PMKError)?.isCancelled ?? false) { + XCTAssertEqual(error, $0 as? Error) + } + ex.fulfill() + }.cancel() + wait(for: [ex], timeout: 1) + } + + for error in [Error.dummy, Error.cancelled] { + helper(policy: .allErrors, error: error) + } + helper(policy: .allErrorsExceptCancellation, error: Error.dummy) + } + + func test__conditional_recover__ignores_cancellation_but_fed_cancellation() { + let ex = expectation(description: "") + CancellablePromise(error: Error.cancelled).recover(policy: .allErrorsExceptCancellation) { _ -> CancellablePromise in + XCTFail() + return Promise.value(1).cancellize() + }.catch(policy: .allErrors) { + XCTAssertEqual(Error.cancelled, $0 as? Error) + ex.fulfill() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func test__conditional_recover__fulfilled_path() { + let ex = expectation(description: "") + Promise.value(1).cancellize().recover { err -> CancellablePromise in + XCTFail() + throw err + }.done { + XCTFail() + XCTAssertEqual($0, 1) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func test__cancellable_conditional_recover__fulfilled_path() { + let ex = expectation(description: "") + Promise.value(1).cancellize().recover { err -> Promise in + XCTFail() + throw err + }.done { + XCTFail() + XCTAssertEqual($0, 1) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func testEnsureThen_Error() { + let ex = expectation(description: "") + + let p = Promise.value(1).cancellize().done { + XCTAssertEqual($0, 1) + throw Error.dummy + }.ensureThen { + return after(seconds: 0.01).cancellize() + }.catch(policy: .allErrors) { + XCTAssert(($0 as? PMKError)?.isCancelled ?? false) + }.finally { + ex.fulfill() + } + p.cancel() + + wait(for: [ex], timeout: 1) + } + + func testEnsureThen_Value() { + let ex = expectation(description: "") + + Promise.value(1).cancellize().ensureThen { + after(seconds: 0.01).cancellize() + }.done { _ in + XCTFail() + }.catch(policy: .allErrors) { + if !$0.isCancelled { + XCTFail() + } + }.finally { + ex.fulfill() + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testEnsureThen_Value_NotCancelled() { + let ex = expectation(description: "") + + Promise.value(1).cancellize().ensureThen { + after(seconds: 0.01).cancellize() + }.done { + XCTAssertEqual($0, 1) + }.catch(policy: .allErrors) { _ in + XCTFail() + }.finally { + ex.fulfill() + } + + wait(for: [ex], timeout: 1) + } + + func testCancellableFinalizerHelpers() { + let ex = expectation(description: "") + + let f = Promise.value(1).cancellize().done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + f.cancel() + + XCTAssertEqual(f.isCancelled, true) + XCTAssertEqual(f.cancelAttempted, true) + XCTAssert(f.cancelledError?.isCancelled ?? false) + + wait(for: [ex], timeout: 1) + } + + func testCancellableRecoverFromError() { + let ex = expectation(description: "") + + let p = Promise(error: PMKError.emptySequence).cancellize().recover(policy: .allErrors) { _ in + Promise.value(1) + }.done { + XCTAssertEqual($0, 1) + ex.fulfill() + } + let f = p.catch(policy: .allErrors) { _ in + XCTFail() + } + + XCTAssertEqual(f.isCancelled, false) + XCTAssertEqual(f.cancelAttempted, false) + XCTAssert(f.cancelledError == nil) + XCTAssert(p.cancelledError == nil) + + wait(for: [ex], timeout: 1) + + XCTAssertEqual(p.isPending, false) + XCTAssertEqual(p.isResolved, true) + XCTAssertEqual(p.isFulfilled, true) + } +} + +private enum Error: CancellableError { + case dummy + case cancelled + + var isCancelled: Bool { + return self == Error.cancelled + } +} diff --git a/Tests/Cancel/DefaultDispatchQueueTests.swift b/Tests/Cancel/DefaultDispatchQueueTests.swift new file mode 100644 index 000000000..2cc3c79ee --- /dev/null +++ b/Tests/Cancel/DefaultDispatchQueueTests.swift @@ -0,0 +1,74 @@ +import class Foundation.Thread +import PromiseKit +import Dispatch +import XCTest + +private enum Error: Swift.Error { case dummy } + +class CancellableDefaultDispatchQueueTest: XCTestCase { + + let myQueue = DispatchQueue(label: "myQueue") + + override func setUp() { + // can actually only set the default queue once + // - See: PMKSetDefaultDispatchQueue + conf.Q = (myQueue, myQueue) + } + + override func tearDown() { + conf.Q = (.main, .main) + } + + func testOverrodeDefaultThenQueue() { + let ex = expectation(description: "resolving") + + let p = Promise.value(1).cancellize() + p.cancel() + p.then { _ -> CancellablePromise in + XCTFail() + XCTAssertFalse(Thread.isMainThread) + return CancellablePromise() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + XCTAssertFalse(Thread.isMainThread) + } + + XCTAssertTrue(Thread.isMainThread) + + waitForExpectations(timeout: 1) + } + + func testOverrodeDefaultCatchQueue() { + let ex = expectation(description: "resolving") + + let p = CancellablePromise(error: Error.dummy) + p.cancel() + p.catch { _ in + ex.fulfill() + XCTAssertFalse(Thread.isMainThread) + } + + XCTAssertTrue(Thread.isMainThread) + + waitForExpectations(timeout: 1) + } + + func testOverrodeDefaultAlwaysQueue() { + let ex = expectation(description: "resolving") + let ex2 = expectation(description: "catching") + + let p = Promise.value(1).cancellize() + p.cancel() + p.ensure { + ex.fulfill() + XCTAssertFalse(Thread.isMainThread) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex2.fulfill() : XCTFail() + XCTAssertFalse(Thread.isMainThread) + } + + XCTAssertTrue(Thread.isMainThread) + + waitForExpectations(timeout: 1) + } +} diff --git a/Tests/Cancel/DispatcherTests.swift b/Tests/Cancel/DispatcherTests.swift new file mode 100644 index 000000000..0274212cb --- /dev/null +++ b/Tests/Cancel/DispatcherTests.swift @@ -0,0 +1,205 @@ +import Dispatch +import PromiseKit +import XCTest + +fileprivate let queueIDKey = DispatchSpecificKey() + +class RecordingDispatcher: Dispatcher { + + static var queueIndex = 1 + + var dispatchCount = 0 + let queue: DispatchQueue + + init() { + queue = DispatchQueue(label: "org.promisekit.testqueue \(RecordingDispatcher.queueIndex)") + RecordingDispatcher.queueIndex += 1 + } + + func dispatch(_ body: @escaping () -> Void) { + dispatchCount += 1 + queue.async(execute: body) + } + +} + +class DispatcherTests: XCTestCase { + + var dispatcher = RecordingDispatcher() + + override func setUp() { + dispatcher = RecordingDispatcher() + } + + func testDispatcherWithThrow() { + let ex = expectation(description: "Dispatcher with throw") + CancellablePromise { seal in + seal.fulfill(42) + }.map(on: dispatcher) { _ in + throw PMKError.badInput + }.catch(on: dispatcher) { _ in + ex.fulfill() + } + waitForExpectations(timeout: 1) + XCTAssertEqual(self.dispatcher.dispatchCount, 2) + } + + func testDispatchQueueSelection() { + + let ex = expectation(description: "DispatchQueue compatibility") + + let oldConf = PromiseKit.conf.D + PromiseKit.conf.D = (map: dispatcher, return: dispatcher) + + let background = DispatchQueue.global(qos: .background) + background.setSpecific(key: queueIDKey, value: 100) + DispatchQueue.main.setSpecific(key: queueIDKey, value: 102) + dispatcher.queue.setSpecific(key: queueIDKey, value: 103) + + Promise.value(42).cancellize().map(on: .global(qos: .background), flags: .barrier) { (x: Int) -> Int in + let queueID = DispatchQueue.getSpecific(key: queueIDKey) + XCTAssertNotNil(queueID) + XCTAssertEqual(queueID!, 100) + return x + 10 + }.get(on: .global(qos: .background), flags: .barrier) { _ in + }.tap(on: .global(qos: .background), flags: .barrier) { _ in + }.then(on: .main, flags: []) { (x: Int) -> CancellablePromise in + XCTAssertEqual(x, 52) + let queueID = DispatchQueue.getSpecific(key: queueIDKey) + XCTAssertNotNil(queueID) + XCTAssertEqual(queueID!, 102) + return Promise.value(50).cancellize() + }.map(on: nil) { (x: Int) -> Int in + let queueID = DispatchQueue.getSpecific(key: queueIDKey) + XCTAssertNotNil(queueID) + XCTAssertEqual(queueID!, 102) + return x + 10 + }.map { (x: Int) -> Int in + XCTAssertEqual(x, 60) + let queueID = DispatchQueue.getSpecific(key: queueIDKey) + XCTAssertNotNil(queueID) + XCTAssertEqual(queueID!, 103) + return x + 10 + }.done(on: background) { + XCTAssertEqual($0, 70) + let queueID = DispatchQueue.getSpecific(key: queueIDKey) + XCTAssertNotNil(queueID) + XCTAssertEqual(queueID!, 100) + ex.fulfill() + }.cauterize() + + waitForExpectations(timeout: 1) + PromiseKit.conf.D = oldConf + + } + + func testMapValues() { + let ex1 = expectation(description: "DispatchQueue MapValues compatibility") + Promise.value([42, 52]).cancellize().then(on: .global(qos: .background), flags: .barrier) { v -> Promise<[Int]> in + Promise.value(v) + }.compactMap(on: .global(qos: .background), flags: .barrier) { + $0 + }.mapValues(on: .global(qos: .background), flags: .barrier) { + $0 + 10 + }.flatMapValues(on: .global(qos: .background), flags: .barrier) { + [$0 + 10] + }.compactMapValues(on: .global(qos: .background), flags: .barrier) { + $0 + 10 + }.thenMap(on: .global(qos: .background), flags: .barrier) { v -> CancellablePromise in + Promise.value(v + 10).cancellize() + }.thenMap(on: .global(qos: .background), flags: .barrier) { v -> Promise in + Promise.value(v + 10) + }.thenFlatMap(on: .global(qos: .background), flags: .barrier) { + Promise.value([$0 + 10]).cancellize() + }.thenFlatMap(on: .global(qos: .background), flags: .barrier) { v -> Promise<[Int]> in + Promise.value([v + 10]) + }.filterValues(on: .global(qos: .background), flags: .barrier) { _ in + true + }.sortedValues(on: .global(qos: .background), flags: .barrier).firstValue(on: .global(qos: .background), flags: .barrier) { _ in + true + }.done(on: .global(qos: .background), flags: .barrier) { + XCTAssertEqual($0, 112) + ex1.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier) { _ in + XCTFail() + } + + let ex2 = expectation(description: "DispatchQueue firstValue property") + Promise.value([42, 52]).cancellize().firstValue.done(on: .global(qos: .background), flags: .barrier) { + XCTAssertEqual($0, 42) + ex2.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier, policy: .allErrors) { _ in + XCTFail() + } + + let ex3 = expectation(description: "DispatchQueue lastValue property") + Promise.value([42, 52]).cancellize().lastValue.done(on: .global(qos: .background), flags: .barrier) { + XCTAssertEqual($0, 52) + ex3.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier, policy: .allErrors) { _ in + XCTFail() + } + + waitForExpectations(timeout: 1) + } + + func testRecover() { + let ex1 = expectation(description: "DispatchQueue CatchMixin compatibility") + Promise.value(42).cancellize().recover(on: .global(qos: .background), flags: .barrier) { _ in + Promise.value(42) + }.ensure(on: .global(qos: .background), flags: .barrier) { + }.ensureThen(on: .global(qos: .background), flags: .barrier) { + Promise.value(42).asVoid().cancellize() + }.recover(on: .global(qos: .background), flags: .barrier) { _ in + Promise.value(42).cancellize() + }.recover(on: .global(qos: .background), flags: .barrier) { _ in + Promise.value(42) + }.done(on: .global(qos: .background), flags: .barrier) { + XCTAssertEqual($0, 42) + ex1.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier) { _ in + XCTFail() + } + + let ex2 = expectation(description: "DispatchQueue CatchMixin Void recover") + firstly { + Promise.value(42).asVoid() + }.cancellize().recover(on: .global(qos: .background), flags: .barrier) { _ in + }.done { + ex2.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier) { _ in + XCTFail() + } + + waitForExpectations(timeout: 1) + } + + @available(macOS 10.10, iOS 2.0, tvOS 10.0, watchOS 2.0, *) + func testDispatcherExtensionReturnsGuarantee() { + let ex = expectation(description: "Dispatcher.promise") + dispatcher.dispatch() { () -> Int in + XCTAssertFalse(Thread.isMainThread) + return 1 + }.cancellize().done { one in + XCTAssertEqual(one, 1) + ex.fulfill() + }.catch { _ in + XCTFail() + } + waitForExpectations(timeout: 1) + } + + @available(macOS 10.10, iOS 2.0, tvOS 10.0, watchOS 2.0, *) + func testDispatcherExtensionCanThrowInBody() { + let ex = expectation(description: "Dispatcher.promise") + dispatcher.dispatch() { () -> Int in + throw PMKError.badInput + }.cancellize().done { _ in + XCTFail() + }.catch { _ in + ex.fulfill() + } + waitForExpectations(timeout: 1) + } + +} diff --git a/Tests/Cancel/ErrorTests.swift b/Tests/Cancel/ErrorTests.swift new file mode 100644 index 000000000..b97c7c8ab --- /dev/null +++ b/Tests/Cancel/ErrorTests.swift @@ -0,0 +1,12 @@ +import PromiseKit +import XCTest + +class CancellableErrorTests: XCTestCase { + func testCustomStringConvertible() { + XCTAssertNotNil(PMKError.cancelled.errorDescription) + } + + func testCustomDebugStringConvertible() { + XCTAssertFalse(PMKError.cancelled.debugDescription.isEmpty) + } +} diff --git a/Tests/Cancel/GuaranteeTests.swift b/Tests/Cancel/GuaranteeTests.swift new file mode 100644 index 000000000..51f3a9f9f --- /dev/null +++ b/Tests/Cancel/GuaranteeTests.swift @@ -0,0 +1,119 @@ +import PromiseKit +import XCTest + +class GuaranteeTests: XCTestCase { + func testInit() { + let ex = expectation(description: "") + Guarantee { seal in + seal(1) + }.cancellize().done { + XCTFail() + XCTAssertEqual(1, $0) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func testWait() { + let ex = expectation(description: "") + do { + let p = after(.milliseconds(100)).cancellize().map(on: nil) { 1 } + p.cancel() + let value = try p.wait() + XCTAssertEqual(value, 1) + } catch { + error.isCancelled ? ex.fulfill() : XCTFail() + } + wait(for: [ex], timeout: 1) + } + + func testThenMap() { + let ex = expectation(description: "") + + Guarantee.value([1, 2, 3]).cancellize().thenMap { Guarantee.value($0 * 2).cancellize() } + .done { values in + XCTAssertEqual([], values) + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testCancellable() { +#if swift(>=4.0) + var resolver: ((()) -> Void)! +#else + var resolver: ((Void) -> Void)! +#endif + + let task = DispatchWorkItem { +#if swift(>=4.0) + resolver(()) +#else + resolver() +#endif + } + + q.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: task) + + let g = Guarantee(cancellable: task) { seal in + resolver = seal + } + + let ex = expectation(description: "") + firstly { + CancellablePromise(g) + }.done { + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("\($0)") + } .cancel() + + wait(for: [ex], timeout: 1) + } + + func testSetCancellable() { +#if swift(>=4.0) + var resolver: ((()) -> Void)! +#else + var resolver: ((Void) -> Void)! +#endif + + let task = DispatchWorkItem { +#if swift(>=4.0) + resolver(()) +#else + resolver() +#endif + } + + q.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: task) + + let g = Guarantee { seal in + resolver = seal + } + g.setCancellable(task) + + let ex = expectation(description: "") + firstly { + g + }.cancellize().done { + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("\($0)") + } .cancel() + + wait(for: [ex], timeout: 1) + } +} + +private var q: DispatchQueue { + if #available(macOS 10.10, iOS 8.0, tvOS 9.0, watchOS 2.0, *) { + return DispatchQueue.global(qos: .default) + } else { + return DispatchQueue.global(priority: .default) + } +} diff --git a/Tests/Cancel/HangTests.swift b/Tests/Cancel/HangTests.swift new file mode 100644 index 000000000..5c5524399 --- /dev/null +++ b/Tests/Cancel/HangTests.swift @@ -0,0 +1,48 @@ +import PromiseKit +import XCTest + +class HangTests: XCTestCase { + func test() { + let ex = expectation(description: "block executed") + do { + let p = after(seconds: 0.02).cancellize().then { _ -> Promise in + XCTFail() + return Promise.value(1) + } + p.cancel() + let value = try hang(p) + XCTFail() + XCTAssertEqual(value, 1) + } catch { + error.isCancelled ? ex.fulfill() : XCTFail("Unexpected error") + } + waitForExpectations(timeout: 0) + } + + enum Error: Swift.Error { + case test + } + + func testError() { + var value = 0 + do { + let p = after(seconds: 0.02).cancellize().done { + XCTFail() + value = 1 + throw Error.test + } + p.cancel() + _ = try hang(p) + XCTFail() + XCTAssertEqual(value, 1) + } catch Error.test { + XCTFail() + } catch { + if !error.isCancelled { + XCTFail("Unexpected error (expected PMKError.cancelled)") + } + return + } + XCTFail("Expected error but no error was thrown") + } +} diff --git a/Tests/Cancel/PromiseTests.swift b/Tests/Cancel/PromiseTests.swift new file mode 100644 index 000000000..be8dba451 --- /dev/null +++ b/Tests/Cancel/PromiseTests.swift @@ -0,0 +1,286 @@ +import PromiseKit +import Dispatch +import XCTest + +class PromiseTests: XCTestCase { + func testIsPending() { + XCTAssertTrue(CancellablePromise.pending().promise.promise.isPending) + XCTAssertFalse(CancellablePromise().promise.isPending) + XCTAssertFalse(CancellablePromise(error: Error.dummy).promise.isPending) + } + + func testIsResolved() { + XCTAssertFalse(CancellablePromise.pending().promise.promise.isResolved) + XCTAssertTrue(CancellablePromise().promise.isResolved) + XCTAssertTrue(CancellablePromise(error: Error.dummy).promise.isResolved) + } + + func testIsFulfilled() { + XCTAssertFalse(CancellablePromise.pending().promise.promise.isFulfilled) + XCTAssertTrue(CancellablePromise().promise.isFulfilled) + XCTAssertFalse(CancellablePromise(error: Error.dummy).promise.isFulfilled) + } + + func testIsRejected() { + XCTAssertFalse(CancellablePromise.pending().promise.promise.isRejected) + XCTAssertTrue(CancellablePromise(error: Error.dummy).promise.isRejected) + XCTAssertFalse(CancellablePromise().promise.isRejected) + } + + @available(macOS 10.10, iOS 2.0, tvOS 10.0, watchOS 2.0, *) + func testDispatchQueueAsyncExtensionReturnsPromise() { + let ex = expectation(description: "") + + DispatchQueue.global().async(.promise) { () -> Int in + usleep(100000) + XCTAssertFalse(Thread.isMainThread) + return 1 + }.cancellize().done { one in + XCTFail() + XCTAssertEqual(one, 1) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("Error: \($0)") + }.cancel() + + waitForExpectations(timeout: 1) + } + + @available(macOS 10.10, iOS 2.0, tvOS 10.0, watchOS 2.0, *) + func testDispatchQueueAsyncExtensionCanThrowInBody() { + let ex = expectation(description: "") + + DispatchQueue.global().async(.promise) { () -> Int in + throw Error.dummy + }.cancellize().done { _ in + XCTFail() + }.catch(policy: .allErrors) { _ in + ex.fulfill() + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testCustomStringConvertible() { + XCTAssertEqual(CancellablePromise.pending().promise.promise.debugDescription, "Promise.pending(handlers: 0)") + XCTAssertEqual(CancellablePromise().promise.debugDescription, "Promise<()>.success(())") + XCTAssertEqual(CancellablePromise(error: Error.dummy).promise.debugDescription, "Promise.failure(Error.dummy)") + + XCTAssertEqual("\(CancellablePromise.pending().promise.promise)", "Promise(…Int)") + XCTAssertEqual("\(Promise.value(3).cancellize().promise)", "Promise(3)") + XCTAssertEqual("\(CancellablePromise(error: Error.dummy).promise)", "Promise(dummy)") + } + + func testCannotFulfillWithError() { + + // sadly this test proves the opposite :( + // left here so maybe one day we can prevent instantiation of `CancellablePromise` + + _ = CancellablePromise { seal in + seal.fulfill(Error.dummy) + } + + _ = CancellablePromise.pending() + + _ = Promise.value(Error.dummy).cancellize() + + _ = CancellablePromise().map { Error.dummy } + } + +#if swift(>=3.1) + func testCanMakeVoidPromise() { + _ = CancellablePromise() + _ = Guarantee() + } +#endif + + enum Error: Swift.Error { + case dummy + } + + func testThrowInInitializer() { + let p = CancellablePromise { _ in + throw Error.dummy + } + p.cancel() + XCTAssertTrue(p.promise.isRejected) + guard let err = p.promise.error, case Error.dummy = err else { return XCTFail() } + } + + func testThrowInFirstly() { + let ex = expectation(description: "") + + firstly { () -> CancellablePromise in + throw Error.dummy + }.catch { + XCTAssertEqual($0 as? Error, Error.dummy) + ex.fulfill() + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testWait() throws { + let p = after(.milliseconds(100)).cancellize().then(on: nil){ Promise.value(1) } + p.cancel() + do { + _ = try p.wait() + XCTFail() + } catch { + XCTAssert(error.isCancelled) + } + + do { + let p = after(.milliseconds(100)).cancellize().map(on: nil){ throw Error.dummy } + p.cancel() + try p.wait() + XCTFail() + } catch { + XCTAssert(error.isCancelled) + } + } + + func testPipeForResolved() { + let ex = expectation(description: "") + Promise.value(1).cancellize().done { + XCTFail() + XCTAssertEqual(1, $0) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("\($0)") + }.cancel() + wait(for: [ex], timeout: 1) + } + + func testCancellable() { + var resolver: Resolver! + + let task = DispatchWorkItem { +#if swift(>=4.0) + resolver.fulfill(()) +#else + resolver.fulfill() +#endif + } + + q.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: task) + + let p = Promise(cancellable: task) { seal in + resolver = seal + } + + let ex = expectation(description: "") + firstly { + CancellablePromise(p) + }.done { + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("\($0)") + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testSetCancellable() { + var resolver: Resolver! + + let task = DispatchWorkItem { +#if swift(>=4.0) + resolver.fulfill(()) +#else + resolver.fulfill() +#endif + } + + q.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: task) + + var reject: ((Swift.Error) -> Void)? + let p = Promise(cancellable: task) { seal in + resolver = seal + reject = seal.reject + } + p.setCancellable(task, reject: reject) + + let ex = expectation(description: "") + firstly { + p + }.cancellize().done { + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("\($0)") + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testInitCancellable() { + var resolver: Resolver! + + let task = DispatchWorkItem { +#if swift(>=4.0) + resolver.fulfill(()) +#else + resolver.fulfill() +#endif + } + + q.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: task) + + let p = Promise { seal in + resolver = seal + } + + let ex = expectation(description: "") + firstly { + CancellablePromise(cancellable: task, promise: p, resolver: resolver) + }.done { + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("\($0)") + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testInitVoidCancellable() { + let task = DispatchWorkItem { } + q.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: task) + + let ex = expectation(description: "") + firstly { + CancellablePromise(cancellable: task) + }.done { + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail("\($0)") + }.cancel() + + wait(for: [ex], timeout: 1) + } + + func testBodyThrowsError() { + let task = DispatchWorkItem { } + q.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: task) + + let p = Promise(cancellable: task) { seal in + throw PMKError.badInput + } + + let ex = expectation(description: "") + firstly { + CancellablePromise(p) + }.done { + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? XCTFail("\($0)") : ex.fulfill() + }.cancel() + + wait(for: [ex], timeout: 1) + } +} + +private var q: DispatchQueue { + if #available(macOS 10.10, iOS 8.0, tvOS 9.0, watchOS 2.0, *) { + return DispatchQueue.global(qos: .default) + } else { + return DispatchQueue.global(priority: .default) + } +} diff --git a/Tests/Cancel/RaceTests.swift b/Tests/Cancel/RaceTests.swift new file mode 100644 index 000000000..d2ebadfbc --- /dev/null +++ b/Tests/Cancel/RaceTests.swift @@ -0,0 +1,107 @@ +import XCTest +import PromiseKit + +class RaceTests: XCTestCase { + func test1() { + let ex = expectation(description: "") + let after1 = after(.milliseconds(10)).cancellize() + let after2 = after(seconds: 1).cancellize() + race(after1.then{ Promise.value(1) }, after2.map { 2 }).done { index in + XCTFail() + XCTAssertEqual(index, 1) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + XCTAssert(after1.isCancelled) + XCTAssert(after2.isCancelled) + XCTAssert(after1.cancelAttempted) + XCTAssert(after2.cancelAttempted) + waitForExpectations(timeout: 1, handler: nil) + } + + func test2() { + let ex = expectation(description: "") + let after1 = after(seconds: 1).cancellize().map { 1 } + let after2 = after(.milliseconds(10)).cancellize().map { 2 } + race(after1, after2).done { index in + XCTFail() + XCTAssertEqual(index, 2) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + XCTAssert(after1.isCancelled) + XCTAssert(after2.isCancelled) + XCTAssert(after1.cancelAttempted) + XCTAssert(after2.cancelAttempted) + waitForExpectations(timeout: 1, handler: nil) + } + + func test1Array() { + let ex = expectation(description: "") + let promises = [after(.milliseconds(10)).cancellize().map { 1 }, after(seconds: 1).cancellize().map { 2 }] + race(promises).done { index in + XCTAssertEqual(index, 1) + ex.fulfill() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + for p in promises { + XCTAssert(p.cancelAttempted) + XCTAssert(p.isCancelled) + } + waitForExpectations(timeout: 1, handler: nil) + } + + func test2Array() { + let ex = expectation(description: "") + let after1 = after(seconds: 1).cancellize().map { 1 } + let after2 = after(.milliseconds(10)).cancellize().map { 2 } + race(after1, after2).done { index in + XCTFail() + XCTAssertEqual(index, 2) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + XCTAssert(after1.isCancelled) + XCTAssert(after2.isCancelled) + XCTAssert(after1.cancelAttempted) + XCTAssert(after2.cancelAttempted) + waitForExpectations(timeout: 1, handler: nil) + } + + func testEmptyArray() { + let ex = expectation(description: "") + let empty = [CancellablePromise]() + race(empty).catch { + guard case PMKError.badInput = $0 else { return XCTFail() } + ex.fulfill() + } + wait(for: [ex], timeout: 1) + } + + func testReject() { + let ex = expectation(description: "") + race(CancellablePromise(error: PMKError.timedOut), after(.milliseconds(10)).map{ 2 }.cancellize()).done { index in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + waitForExpectations(timeout: 1, handler: nil) + } + + func testCancelInner() { + let ex = expectation(description: "") + + let after1 = after(.milliseconds(10)).cancellize() + let after2 = after(seconds: 1).cancellize() + let r = race(after1.then{ Promise.value(1).cancellize() }, after2.map { 2 }) + + r.done { index in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + after1.cancel() + waitForExpectations(timeout: 1, handler: nil) + } +} diff --git a/Tests/Cancel/RegressionTests.swift b/Tests/Cancel/RegressionTests.swift new file mode 100644 index 000000000..baa7c58d4 --- /dev/null +++ b/Tests/Cancel/RegressionTests.swift @@ -0,0 +1,57 @@ +import PromiseKit +import XCTest + +class RegressionTests: XCTestCase { + func testReturningPreviousPromiseWorks() { + + // regression test because we were doing this wrong + // in our A+ tests implementation for spec: 2.3.1 + + do { + let ex = expectation(description: "") + let promise1 = CancellablePromise() + promise1.cancel() + let promise2 = promise1.then(on: nil) { promise1 } + promise2.catch(on: nil, policy: .allErrors) { + ex.fulfill() + if !$0.isCancelled { + XCTFail() + } + } + wait(for: [ex], timeout: 1) + } + + do { + let ex = expectation(description: "") + let promise1 = CancellablePromise() + promise1.cancel() + let promise2 = promise1.then(on: nil) { () -> CancellablePromise in + XCTFail() + return promise1 + } + promise2.catch(on: nil, policy: .allErrors) { + ex.fulfill() + if !$0.isCancelled { + XCTFail() + } + } + wait(for: [ex], timeout: 1) + } + + do { + let ex = expectation(description: "") + enum Error: Swift.Error { case dummy } + + let promise1 = CancellablePromise(error: Error.dummy) + promise1.cancel() + let promise2 = promise1.recover(on: nil) { _ in promise1 } + promise2.catch(on: nil, policy: .allErrors) { err in + if case PMKError.returnedSelf = err { + XCTFail() + } + ex.fulfill() + } + wait(for: [ex], timeout: 1) + } + } +} diff --git a/Tests/Cancel/ResolverTests.swift b/Tests/Cancel/ResolverTests.swift new file mode 100644 index 000000000..98124ffa6 --- /dev/null +++ b/Tests/Cancel/ResolverTests.swift @@ -0,0 +1,340 @@ +import PromiseKit +import XCTest + +class WrapTests: XCTestCase { + fileprivate class KittenFetcher { + let value: Int? + let error: Error? + + init(value: Int?, error: Error?) { + self.value = value + self.error = error + } + + func fetchWithCompletionBlock(block: @escaping(Int?, Error?) -> Void) { + after(.milliseconds(20)).done { + block(self.value, self.error) + } + } + + func fetchWithCompletionBlock2(block: @escaping(Error?, Int?) -> Void) { + after(.milliseconds(20)).done { + block(self.error, self.value) + } + } + + func fetchWithCompletionBlock3(block: @escaping(Int, Error?) -> Void) { + after(.milliseconds(20)).done { + block(self.value ?? -99, self.error) + } + } + + func fetchWithCompletionBlock4(block: @escaping(Error?) -> Void) { + after(.milliseconds(20)).done { + block(self.error) + } + } + } + + fileprivate class CancellableKittenFetcher: Cancellable { + func cancel() { + finalizer?.cancel() + } + + var isCancelled: Bool { + return finalizer?.isCancelled ?? false + } + + let value: Int? + let error: Swift.Error? + var finalizer: CancellableFinalizer? + + init(value: Int?, error: Swift.Error?) { + self.value = value + self.error = error + } + + func fetchWithCompletionBlock(block: @escaping(Int?, Swift.Error?) -> Void) { + finalizer = after(.milliseconds(20)).cancellize().done {_ in + block(self.value, self.error) + }.catch(policy: .allErrors) { + block(nil, $0) + } + } + + func fetchWithCompletionBlock2(block: @escaping(Swift.Error?, Int?) -> Void) { + finalizer = after(.milliseconds(20)).cancellize().done { + block(self.error, self.value) + }.catch(policy: .allErrors) { + block($0, nil) + } + } + + func fetchWithCompletionBlock3(block: @escaping(Int, Swift.Error?) -> Void) { + finalizer = after(.milliseconds(20)).cancellize().done { + block(self.value ?? -99, self.error) + }.catch(policy: .allErrors) { + block(-99, $0) + } + } + + func fetchWithCompletionBlock4(block: @escaping(Swift.Error?) -> Void) { + finalizer = after(.milliseconds(20)).cancellize().done { + block(self.error) + }.catch(policy: .allErrors) { + block($0) + } + } + } + + func testSuccess() { + let ex = expectation(description: "") + let kittenFetcher = KittenFetcher(value: 2, error: nil) + CancellablePromise { seal in + kittenFetcher.fetchWithCompletionBlock(block: seal.resolve) + }.done { + XCTFail() + XCTAssertEqual($0, 2) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1) + } + + func testError() { + let ex = expectation(description: "") + + let kittenFetcher = KittenFetcher(value: nil, error: Error.test) + CancellablePromise { seal in + kittenFetcher.fetchWithCompletionBlock(block: seal.resolve) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testErrorNoDelay() { + let ex = expectation(description: "") + + CancellablePromise { seal in + seal.resolve(nil, Error.test) + }.catch(policy: .allErrors) { error in + defer { ex.fulfill() } + guard case Error.test = error else { + return XCTFail() + } + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testErrorCancellableKitten() { + let ex = expectation(description: "") + + let kittenFetcher = CancellableKittenFetcher(value: nil, error: Error.test) + CancellablePromise(cancellable: kittenFetcher) { seal in + kittenFetcher.fetchWithCompletionBlock(block: seal.resolve) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testInvalidCallingConvention() { + let ex = expectation(description: "") + + let kittenFetcher = KittenFetcher(value: nil, error: nil) + CancellablePromise { seal in + kittenFetcher.fetchWithCompletionBlock(block: seal.resolve) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testInvalidCallingConventionCancellableKitten() { + let ex = expectation(description: "") + + let kittenFetcher = CancellableKittenFetcher(value: nil, error: nil) + CancellablePromise(cancellable: kittenFetcher) { seal in + kittenFetcher.fetchWithCompletionBlock(block: seal.resolve) + }.catch(policy: .allErrors) { (error: Swift.Error) -> Void in + error.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testInvertedCallingConvention() { + let ex = expectation(description: "") + let kittenFetcher = KittenFetcher(value: 2, error: nil) + CancellablePromise { seal in + kittenFetcher.fetchWithCompletionBlock2(block: seal.resolve) + }.done { + XCTFail() + XCTAssertEqual($0, 2) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testInvertedCallingConventionCancellableKitten() { + let ex = expectation(description: "") + let kittenFetcher = CancellableKittenFetcher(value: 2, error: nil) + CancellablePromise(cancellable: kittenFetcher) { seal in + kittenFetcher.fetchWithCompletionBlock2(block: seal.resolve) + }.done { + XCTFail() + XCTAssertEqual($0, 2) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testNonOptionalFirstParameter() { + let ex1 = expectation(description: "") + let kf1 = KittenFetcher(value: 2, error: nil) + CancellablePromise { seal in + kf1.fetchWithCompletionBlock3(block: seal.resolve) + }.done { + XCTFail() + XCTAssertEqual($0, 2) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex1.fulfill() : XCTFail() + }.cancel() + + let ex2 = expectation(description: "") + let kf2 = KittenFetcher(value: -100, error: Error.test) + CancellablePromise { seal in + kf2.fetchWithCompletionBlock3(block: seal.resolve) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex2.fulfill() : XCTFail() + }.cancel() + + wait(for: [ex1, ex2] ,timeout: 1) + } + + func testNonOptionalFirstParameterCancellableKitten() { + let ex1 = expectation(description: "") + let kf1 = CancellableKittenFetcher(value: 2, error: nil) + CancellablePromise(cancellable: kf1) { seal in + kf1.fetchWithCompletionBlock3(block: seal.resolve) + }.done { + XCTAssertEqual($0, 2) + ex1.fulfill() + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex1.fulfill() : XCTFail() + }.cancel() + + let ex2 = expectation(description: "") + let kf2 = CancellableKittenFetcher(value: -100, error: Error.test) + CancellablePromise(cancellable: kf2) { seal in + kf2.fetchWithCompletionBlock3(block: seal.resolve) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex2.fulfill() : XCTFail() + }.cancel() + + wait(for: [ex1, ex2] ,timeout: 1) + } + +#if swift(>=3.1) + func testVoidCompletionValue() { + let ex1 = expectation(description: "") + let kf1 = KittenFetcher(value: nil, error: nil) + CancellablePromise { seal in + kf1.fetchWithCompletionBlock4(block: seal.resolve) + }.done(ex1.fulfill).catch(policy: .allErrors) { error in + error.isCancelled ? ex1.fulfill() : XCTFail() + }.cancel() + + let ex2 = expectation(description: "") + let kf2 = KittenFetcher(value: nil, error: Error.test) + CancellablePromise { seal in + kf2.fetchWithCompletionBlock4(block: seal.resolve) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex2.fulfill() : XCTFail() + }.cancel() + + wait(for: [ex1, ex2], timeout: 1) + } + + func testVoidCompletionValueCancellableKitten() { + let ex1 = expectation(description: "") + let kf1 = CancellableKittenFetcher(value: nil, error: nil) + CancellablePromise(cancellable: kf1) { seal in + kf1.fetchWithCompletionBlock4(block: seal.resolve) + }.done(ex1.fulfill).catch(policy: .allErrors) { error in + error.isCancelled ? ex1.fulfill() : XCTFail() + }.cancel() + + let ex2 = expectation(description: "") + let kf2 = CancellableKittenFetcher(value: nil, error: Error.test) + CancellablePromise(cancellable: kf2) { seal in + kf2.fetchWithCompletionBlock4(block: seal.resolve) + }.catch(policy: .allErrors) { error in + error.isCancelled ? ex2.fulfill() : XCTFail() + }.cancel() + + wait(for: [ex1, ex2], timeout: 1) + } +#endif + + func testIsFulfilled() { + let p1 = Promise.value(()).cancellize() + p1.cancel() + + var success1 = false + if let r1 = p1.result, case .success = r1 { + success1 = true + } + XCTAssertTrue(success1) + XCTAssertTrue(p1.isCancelled) + + let p2 = CancellablePromise(error: Error.test) + p2.cancel() + var success2 = true + if let r2 = p2.result { + if case .success = r2 { + success2 = true + } else { + success2 = false + } + } + XCTAssertFalse(success2) + XCTAssertTrue(p2.isCancelled) + } + + func testPendingPromiseDeallocated() { + + // NOTE this doesn't seem to register the `deinit` as covered :( + // BUT putting a breakpoint in the deinit CLEARLY shows it getting covered… + + class Foo { + let p = CancellablePromise.pending() + var ex: XCTestExpectation! + + deinit { + after(.milliseconds(100)).done(ex.fulfill) + } + } + + let ex = expectation(description: "") + do { + // for code coverage report for `Resolver.deinit` warning + let foo = Foo() + foo.ex = ex + } + wait(for: [ex], timeout: 1) + } +} + +private enum Error: Swift.Error { + case test +} diff --git a/Tests/Cancel/StressTests.swift b/Tests/Cancel/StressTests.swift new file mode 100644 index 000000000..c34a7cb8f --- /dev/null +++ b/Tests/Cancel/StressTests.swift @@ -0,0 +1,173 @@ +@testable import PromiseKit +import Dispatch +import XCTest + +class StressTests: XCTestCase { + func testThenDataRace() { + let e1 = expectation(description: "") + let e2 = expectation(description: "") + var errorCounter = 0 + + //will crash if then doesn't protect handlers + stressDataRace(expectation: e1, iterations: 1000, stressFunction: { promise in + promise.done { s in + XCTFail() + XCTAssertEqual("ok", s) + return + }.catch(policy: .allErrors) { + if !$0.isCancelled { + XCTFail() + } + errorCounter += 1 + if errorCounter == 1000 { + e2.fulfill() + } + }.cancel() + }, fulfill: { "ok" }) + + waitForExpectations(timeout: 10, handler: nil) + } + + @available(macOS 10.10, iOS 2.0, tvOS 10.0, watchOS 2.0, *) + func testThensAreSequentialForLongTime() { + var values = [Int]() + let ex = expectation(description: "") + var promise = DispatchQueue.global().async(.promise){ 0 }.cancellize() + let N = 1000 + for x in 1.. CancellablePromise in + values.append(y) + XCTFail() + return DispatchQueue.global().async(.promise) { x }.cancellize() + } + } + promise.done { x in + values.append(x) + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 10, handler: nil) + } + + func testZalgoDataRace() { + let e1 = expectation(description: "") + let e2 = expectation(description: "") + var errorCounter = 0 + + //will crash if zalgo doesn't protect handlers + stressDataRace(expectation: e1, iterations: 1000, stressFunction: { promise in + promise.done(on: nil) { s in + XCTAssertEqual("ok", s) + }.catch(policy: .allErrors) { + if !$0.isCancelled { + XCTFail() + } + errorCounter += 1 + if errorCounter == 1000 { + e2.fulfill() + } + }.cancel() + }, fulfill: { + return "ok" + }) + + waitForExpectations(timeout: 10, handler: nil) + } + + class StressTask: Cancellable { + init() { + isCancelled = true + } + + func cancel() { + } + + var isCancelled: Bool + } + + func testCancelContextConcurrentReadWrite() { + let e1 = expectation(description: "") + let context = CancelContext() + func consume(error: Swift.Error?) { } + + stressRace(expectation: e1, iterations: 1000, stressFactor: 1000, stressFunction: { + consume(error: context.cancelledError) + }, fulfillFunction: { + context.cancel() + }) + + waitForExpectations(timeout: 10, handler: nil) + } + + func testCancelContextConcurrentAppend() { + let e1 = expectation(description: "") + let context = CancelContext() + let promise = CancellablePromise() + let task = StressTask() + + stressRace(expectation: e1, iterations: 1000, stressFactor: 100, stressFunction: { + context.append(cancellable: task, reject: nil, thenable: promise) + }, fulfillFunction: { + context.append(cancellable: task, reject: nil, thenable: promise) + }) + + waitForExpectations(timeout: 10, handler: nil) + } + + func testCancelContextConcurrentCancel() { + let e1 = expectation(description: "") + let context = CancelContext() + let promise = CancellablePromise() + let task = StressTask() + + stressRace(expectation: e1, iterations: 500, stressFactor: 10, stressFunction: { + context.append(cancellable: task, reject: nil, thenable: promise) + }, fulfillFunction: { + context.cancel() + }) + + waitForExpectations(timeout: 10, handler: nil) + } +} + +private enum Error: Swift.Error { + case Dummy +} + +private func stressDataRace(expectation e1: XCTestExpectation, iterations: Int = 1000, stressFactor: Int = 10, stressFunction: @escaping (CancellablePromise) -> Void, fulfill f: @escaping () -> T) { + let group = DispatchGroup() + let queue = DispatchQueue(label: "the.domain.of.Zalgo", attributes: .concurrent) + + for _ in 0...pending() + + DispatchQueue.concurrentPerform(iterations: stressFactor) { _ in + stressFunction(promise) + } + + queue.async(group: group) { + seal.fulfill(f()) + } + } + + group.notify(queue: queue, execute: e1.fulfill) +} + +private func stressRace(expectation e1: XCTestExpectation, iterations: Int = 10000, stressFactor: Int = 1000, stressFunction: @escaping () -> Void, fulfillFunction: @escaping () -> Void) { + let group = DispatchGroup() + let queue = DispatchQueue(label: "the.domain.of.Zalgo", attributes: .concurrent) + + for _ in 0.. Int in + promise.cancel() + throw E.dummy + }.catch(policy: .allErrors) { + if case E.dummy = $0 {} else { + XCTFail() + } + ex.fulfill() + } + wait(for: [ex], timeout: 1) + } + + func testRejectedPromiseCompactMap() { + + enum E: Error { case dummy } + + let ex = expectation(description: "") + CancellablePromise(error: E.dummy).compactMap { + XCTFail() + }.catch(policy: .allErrors) { + if case E.dummy = $0 {} else { + XCTFail() + } + ex.fulfill() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func testPMKErrorCompactMap() { + let ex = expectation(description: "") + Promise.value("a").cancellize().compactMap { + Int($0) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func testCompactMapValues() { + let ex = expectation(description: "") + let promise = Promise.value(["1","2","a","4"]).cancellize() + promise.compactMapValues { + Int($0) + }.done { + promise.cancel() + XCTAssertEqual([1,2,4], $0) + ex.fulfill() + }.catch(policy: .allErrors) { _ in + XCTFail() + } + wait(for: [ex], timeout: 1) + } + + func testThenMap() { + let ex = expectation(description: "") + let promise = Promise.value([1,2,3,4]).cancellize() + promise.thenMap { (x: Int) -> Promise in + promise.cancel() + return Promise.value(x) // Intentionally use `Promise` rather than `CancellablePromise` + }.done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + wait(for: [ex], timeout: 1) + } + + func testThenFlatMap() { + let ex = expectation(description: "") + Promise.value([1,2,3,4]).cancellize().thenFlatMap { (x: Int) -> CancellablePromise<[Int]> in + XCTFail() + return Promise.value([x, x]).cancellize() + }.done { + XCTFail() + XCTAssertEqual([1,1,2,2,3,3,4,4], $0) + ex.fulfill() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func testLastValueForEmpty() { + XCTAssertTrue(Promise.value([]).cancellize().lastValue.isRejected) + } + + func testFirstValueForEmpty() { + XCTAssertTrue(Promise.value([]).cancellize().firstValue.isRejected) + } + + func testThenOffRejected() { + // surprisingly missing in our CI, mainly due to + // extensive use of `done` in A+ tests since PMK 5 + + let ex = expectation(description: "") + CancellablePromise(error: PMKError.badInput).then { x -> Promise in + XCTFail() + return .value(x) + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + wait(for: [ex], timeout: 1) + } + + func testBarrier() { + let ex = expectation(description: "") + let q = DispatchQueue(label: "\(#file):\(#line)", attributes: .concurrent) + Promise.value(1).cancellize().done(on: q, flags: .barrier) { + XCTAssertEqual($0, 1) + dispatchPrecondition(condition: .onQueueAsBarrier(q)) + ex.fulfill() + }.catch { _ in + XCTFail() + } + wait(for: [ex], timeout: 10) + } + + func testDispatchFlagsSyntax() { + let ex = expectation(description: "") + let q = DispatchQueue(label: "\(#file):\(#line)", attributes: .concurrent) + Promise.value(1).cancellize().done(on: q, flags: [.barrier, .inheritQoS]) { + XCTAssertEqual($0, 1) + dispatchPrecondition(condition: .onQueueAsBarrier(q)) + ex.fulfill() + }.catch { _ in + XCTFail() + } + wait(for: [ex], timeout: 10) + } +} diff --git a/Tests/Cancel/TimeoutTests.swift b/Tests/Cancel/TimeoutTests.swift new file mode 100644 index 000000000..a47f4d01d --- /dev/null +++ b/Tests/Cancel/TimeoutTests.swift @@ -0,0 +1,125 @@ +import PromiseKit +import XCTest + +class TimeoutTests: XCTestCase { + func testTimeout() { + let ex = expectation(description: "") + + race(after(seconds: 0.5).cancellize(), timeout(seconds: 0.01).cancellize()).done { + // race(cancellize(after(seconds: 0.5)), timeout(seconds: 0.01)).done { + XCTFail() + }.catch(policy: .allErrors) { + do { + throw $0 + } catch PMKError.timedOut { + ex.fulfill() + } catch { + XCTFail() + } + } + waitForExpectations(timeout: 1) + } + + func testReset() { + let ex = expectation(description: "") + let p = after(seconds: 0.5).cancellize() + race(p, timeout(seconds: 2.0).cancellize(), timeout(seconds: 0.01).cancellize()).done { + XCTFail() + }.catch(policy: .allErrors) { err in + do { + throw err + } catch PMKError.timedOut { + _ = (err as? PMKError).debugDescription + ex.fulfill() + } catch { + XCTFail() + } + } + waitForExpectations(timeout: 1) + XCTAssert(p.isCancelled) + } + + func testDoubleTimeout() { + let ex = expectation(description: "") + let p = after(seconds: 0.5).cancellize() + race(p, timeout(seconds: 0.01).cancellize(), timeout(seconds: 0.01).cancellize()).done { + XCTFail() + }.catch(policy: .allErrors) { + do { + throw $0 + } catch PMKError.timedOut { + ex.fulfill() + } catch { + XCTFail() + } + } + waitForExpectations(timeout: 1) + XCTAssert(p.isCancelled) + } + + func testNoTimeout() { + let ex = expectation(description: "") + race(after(seconds: 0.01).cancellize(), timeout(seconds: 0.5).cancellize()).then { _ -> Promise in + ex.fulfill() + return Promise.value(1) + }.catch(policy: .allErrors) { _ in + XCTFail() + } + waitForExpectations(timeout: 1) + } + + func testCancelBeforeTimeout() { + let ex = expectation(description: "") + let p = after(seconds: 0.5).cancellize() + race(p, timeout(seconds: 2).cancellize()).then { _ -> Promise in + XCTFail() + return Promise.value(1) + }.catch(policy: .allErrors) { + do { + throw $0 + } catch PMKError.cancelled { + ex.fulfill() + } catch { + XCTFail() + } + } + p.cancel() + waitForExpectations(timeout: 1) + } + + func testCancelRaceBeforeTimeout() { + let ex = expectation(description: "") + let ctxt = race(after(seconds: 0.5).cancellize(), timeout(seconds: 2).cancellize()).then { _ -> Promise in + XCTFail() + return Promise.value(1) + }.catch(policy: .allErrors) { + do { + throw $0 + } catch PMKError.cancelled { + ex.fulfill() + } catch { + XCTFail() + } + }.cancelContext + ctxt.cancel() + waitForExpectations(timeout: 1) + } + + func testMixTypes() { + let ex = expectation(description: "") + let promise1, promise2: CancellablePromise + promise1 = Promise.value("string").cancellize().asVoid() + promise2 = Promise.value(22).cancellize().asVoid() + race(promise1, promise2, + Promise.value("string").cancellize().asVoid(), + Promise.value(22).cancellize().asVoid(), + timeout(seconds: 2).cancellize()).then { thisone -> Promise in + print("\(thisone)") + ex.fulfill() + return Promise.value(1) + }.catch(policy: .allErrors) { _ in + XCTFail() + } + waitForExpectations(timeout: 1) + } +} diff --git a/Tests/Cancel/Utilities.swift b/Tests/Cancel/Utilities.swift new file mode 100644 index 000000000..87a408d4a --- /dev/null +++ b/Tests/Cancel/Utilities.swift @@ -0,0 +1,40 @@ +import PromiseKit + +// Workaround for error with missing libswiftContacts.dylib, this import causes the +// library to be included as needed +#if os(iOS) || os(watchOS) || os(OSX) +import class Contacts.CNPostalAddress +#endif + +extension Promise { + func silenceWarning() {} +} + +extension CancellablePromise { + func silenceWarning() {} +} + +#if os(Linux) +import func CoreFoundation._CFIsMainThread + +extension Thread { + // `isMainThread` is not implemented yet in swift-corelibs-foundation. + static var isMainThread: Bool { + return _CFIsMainThread() + } +} + +import XCTest + +extension XCTestCase { + func wait(for: [XCTestExpectation], timeout: TimeInterval, file: StaticString = #file, line: UInt = #line) { + waitForExpectations(timeout: timeout, file: file, line: Int(line)) + } +} + +extension XCTestExpectation { + func fulfill() { + fulfill(#file, line: #line) + } +} +#endif diff --git a/Tests/Cancel/ValueTests.swift b/Tests/Cancel/ValueTests.swift new file mode 100644 index 000000000..8692a1a58 --- /dev/null +++ b/Tests/Cancel/ValueTests.swift @@ -0,0 +1,139 @@ +import XCTest +import PromiseKit + +class ValueTests: XCTestCase { + func testValueContext() { + let exComplete = expectation(description: "after completes") + Promise.value("hi").cancellize().done { _ in + XCTFail("value not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testValueDone() { + let exComplete = expectation(description: "after completes") + Promise.value("hi").cancellize().done { _ in + XCTFail("value not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testValueThen() { + let exComplete = expectation(description: "after completes") + + Promise.value("hi").cancellize().then { (_: String) -> Promise in + XCTFail("value not cancelled") + return Promise.value("bye") + }.done { _ in + XCTFail("value not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testFirstlyValueDone() { + let exComplete = expectation(description: "after completes") + + firstly { + Promise.value("hi") + }.cancellize().done { _ in + XCTFail("value not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testFirstlyThenValueDone() { + let exComplete = expectation(description: "after completes") + + firstly { + Promise.value("hi").cancellize() + }.then { (_: String) -> CancellablePromise in + XCTFail("'hi' not cancelled") + return Promise.value("there").cancellize() + }.done { _ in + XCTFail("'there' not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testFirstlyValueDifferentContextDone() { + let exComplete = expectation(description: "after completes") + + let p = firstly { + return Promise.value("hi") + }.cancellize().done { _ in + XCTFail("value not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + } + p.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testFirstlyValueDoneDifferentContext() { + let exComplete = expectation(description: "after completes") + + firstly { + Promise.value("hi") + }.cancellize().done { _ in + XCTFail("value not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testCancelForPromise_Then() { + let exComplete = expectation(description: "after completes") + + let promise = CancellablePromise { seal in + usleep(100000) + seal.fulfill(()) + } + promise.then { () throws -> Promise in + XCTFail("then not cancelled") + return Promise.value("x") + }.done { _ in + XCTFail("done not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } + + func testCancelForPromise_ThenDone() { + let exComplete = expectation(description: "done is cancelled") + + let promise = CancellablePromise { seal in + usleep(100000) + seal.fulfill(()) + } + promise.then { _ -> CancellablePromise in + XCTFail("then not cancelled") + return Promise.value("x").cancellize() + }.done { _ in + XCTFail("done not cancelled") + }.catch(policy: .allErrors) { error in + error.isCancelled ? exComplete.fulfill() : XCTFail("error: \(error)") + }.cancel() + + wait(for: [exComplete], timeout: 1) + } +} diff --git a/Tests/Cancel/WhenConcurrentTests.swift b/Tests/Cancel/WhenConcurrentTests.swift new file mode 100644 index 000000000..d2e9879ea --- /dev/null +++ b/Tests/Cancel/WhenConcurrentTests.swift @@ -0,0 +1,275 @@ +import XCTest +import PromiseKit + +class WhenConcurrentTestCase_Swift: XCTestCase { + + func testWhenSucceed() { + let e = expectation(description: "") + + var numbers = (0..<42).makeIterator() + let squareNumbers = numbers.map { $0 * $0 } + + let generator = AnyIterator> { + guard let number = numbers.next() else { + return nil + } + + return after(.milliseconds(10)).cancellize().map { + return number * number + } + } + + when(fulfilled: generator, concurrently: 5).done { numbers in + if numbers == squareNumbers { + e.fulfill() + } + }.silenceWarning() + + waitForExpectations(timeout: 3, handler: nil) + } + + func testWhenCancel() { + let e = expectation(description: "") + + var numbers = (0..<42).makeIterator() + let squareNumbers = numbers.map { $0 * $0 } + + let generator = AnyIterator> { + guard let number = numbers.next() else { + return nil + } + + return after(.milliseconds(10)).cancellize().map { + XCTFail() + return number * number + } + } + + when(fulfilled: generator, concurrently: 5).done { numbers in + XCTFail() + if numbers == squareNumbers { + e.fulfill() + } + }.catch(policy: .allErrors) { + $0.isCancelled ? e.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 3, handler: nil) + } + + func testWhenEmptyGeneratorSucceed() { + let e = expectation(description: "") + + let generator = AnyIterator> { + return nil + } + + when(fulfilled: generator, concurrently: 5).done { numbers in + if numbers.count == 0 { + e.fulfill() + } + }.silenceWarning() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testWhenEmptyGeneratorCancel() { + let e = expectation(description: "") + + let generator = AnyIterator> { + return nil + } + + when(fulfilled: generator, concurrently: 5).done { numbers in + if numbers.count == 0 { + e.fulfill() + } + }.catch(policy: .allErrors) { + $0.isCancelled ? e.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testWhenGeneratorErrorSucceed() { + enum LocalError: Error { + case Unknown + case DivisionByZero + } + + let expectedErrorIndex = 42 + let expectedError = LocalError.DivisionByZero + + let e = expectation(description: "") + + var numbers = (-expectedErrorIndex..> { + guard let number = numbers.next() else { + return nil + } + + return after(.milliseconds(10)).cancellize().then { _ -> Promise in + if number != 0 { + return Promise(error: expectedError) + } else { + return Promise.value(100500 / number) + } + } + } + + when(fulfilled: generator, concurrently: 3).catch { error in + guard let error = error as? LocalError else { + return + } + guard case .DivisionByZero = error else { + return + } + e.fulfill() + } + + waitForExpectations(timeout: 3, handler: nil) + } + + func testWhenGeneratorErrorCancel() { + enum LocalError: Error { + case Unknown + case DivisionByZero + } + + let expectedErrorIndex = 42 + let expectedError = LocalError.DivisionByZero + + let e = expectation(description: "") + + var numbers = (-expectedErrorIndex..> { + guard let number = numbers.next() else { + return nil + } + + return after(.milliseconds(10)).cancellize().then { _ -> CancellablePromise in + if number != 0 { + return CancellablePromise(error: expectedError) + } else { + return Promise.value(100500 / number).cancellize() + } + } + } + + when(fulfilled: generator, concurrently: 3).catch(policy: .allErrors) { error in + error.isCancelled ? e.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 3, handler: nil) + } + + func testWhenConcurrencySucceed() { + let expectedConcurrently = 4 + var currentConcurrently = 0 + var maxConcurrently = 0 + + let e = expectation(description: "") + + var numbers = (0..<42).makeIterator() + + let generator = AnyIterator> { + currentConcurrently += 1 + maxConcurrently = max(maxConcurrently, currentConcurrently) + + guard let number = numbers.next() else { + return nil + } + + return after(.milliseconds(10)).cancellize().then(on: .main) { _ -> Promise in + currentConcurrently -= 1 + return Promise.value(number * number) + } + } + + when(fulfilled: generator, concurrently: expectedConcurrently).done { _ in + XCTAssertEqual(expectedConcurrently, maxConcurrently) + e.fulfill() + }.silenceWarning() + + waitForExpectations(timeout: 3) + } + + func testWhenConcurrencyCancel() { + let expectedConcurrently = 4 + var currentConcurrently = 0 + var maxConcurrently = 0 + + let e = expectation(description: "") + + var numbers = (0..<42).makeIterator() + + let generator = AnyIterator> { + currentConcurrently += 1 + maxConcurrently = max(maxConcurrently, currentConcurrently) + + guard let number = numbers.next() else { + return nil + } + + return after(.milliseconds(10)).cancellize().then(on: .main) { _ -> Promise in + currentConcurrently -= 1 + return Promise.value(number * number) + } + } + + when(fulfilled: generator, concurrently: expectedConcurrently).done { _ in + XCTFail() + XCTAssertEqual(expectedConcurrently, maxConcurrently) + e.fulfill() + }.catch(policy: .allErrors) { + $0.isCancelled ? e.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 3) + } + + func testWhenConcurrencyLessThanZero() { + let generator = AnyIterator> { XCTFail(); return nil } + + let p1 = when(fulfilled: generator, concurrently: 0) + let p2 = when(fulfilled: generator, concurrently: -1) + p1.cancel() + p2.cancel() + + guard let e1 = p1.error else { return XCTFail() } + guard let e2 = p2.error else { return XCTFail() } + guard case PMKError.badInput = e1 else { return XCTFail() } + guard case PMKError.badInput = e2 else { return XCTFail() } + } + + func testStopsDequeueingOnceRejected() { + let ex = expectation(description: "") + enum Error: Swift.Error { case dummy } + + var x: UInt = 0 + let generator = AnyIterator> { + x += 1 + switch x { + case 0: + fatalError() + case 1: + return CancellablePromise() + case 2: + return CancellablePromise(error: Error.dummy) + case _: + XCTFail() + return nil + } + } + + when(fulfilled: generator, concurrently: 1).done { + XCTFail("\($0)") + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 3) + } +} diff --git a/Tests/Cancel/WhenResolvedTests.swift b/Tests/Cancel/WhenResolvedTests.swift new file mode 100644 index 000000000..c3859777f --- /dev/null +++ b/Tests/Cancel/WhenResolvedTests.swift @@ -0,0 +1,69 @@ +// Created by Austin Feight on 3/19/16. +// Copyright © 2016 Max Howell. All rights reserved. + +import PromiseKit +import XCTest + +class JoinTests: XCTestCase { + func testImmediates() { + let successPromise = CancellablePromise() + + var joinFinished = false + when(resolved: successPromise).done(on: nil) { _ in joinFinished = true }.cancel() + XCTAssert(joinFinished, "Join immediately finishes on fulfilled promise") + + let promise2 = Promise.value(2) + let promise3: CancellablePromise = Promise.value(3).cancellize() + let promise4 = Promise.value(4) + var join2Finished = false + when(resolved: CancellablePromise(promise2), promise3, CancellablePromise(promise4)).done(on: nil) { _ in join2Finished = true }.cancel() + XCTAssert(join2Finished, "Join immediately finishes on fulfilled promises") + } + + func testFulfilledAfterAllResolve() { + let (promise1, seal1) = CancellablePromise.pending() + let (promise2, seal2) = Promise.pending() + let (promise3, seal3) = CancellablePromise.pending() + + var finished = false + let promise = when(resolved: promise1, CancellablePromise(promise2), promise3).done(on: nil) { _ in finished = true } + XCTAssertFalse(finished, "Not all promises have resolved") + + seal1.fulfill(()) + XCTAssertFalse(finished, "Not all promises have resolved") + + seal2.fulfill(()) + XCTAssertFalse(finished, "Not all promises have resolved") + + seal3.fulfill(()) + promise.cancel() + XCTAssert(finished, "All promises have resolved") + } + + func testCancelledAfterAllResolve() { + let (promise1, seal1) = CancellablePromise.pending() + let (promise2, seal2) = Promise.pending() + let (promise3, seal3) = CancellablePromise.pending() + + var cancelled = false + let ex = expectation(description: "") + let cp2 = CancellablePromise(promise2) + when(resolved: promise1, cp2, promise3).done(on: nil) { _ in + XCTFail() + }.catch(policy: .allErrors) { + cancelled = $0.isCancelled + cancelled ? ex.fulfill() : XCTFail() + }.cancel() + + seal1.fulfill(()) + seal2.fulfill(()) + seal3.fulfill(()) + + waitForExpectations(timeout: 1) + + XCTAssert(cancelled, "Cancel error caught") + XCTAssert(promise1.isCancelled, "Promise 1 cancelled") + XCTAssert(cp2.isCancelled, "Promise 2 cancelled") + XCTAssert(promise3.isCancelled, "Promise 3 cancelled") + } +} diff --git a/Tests/Cancel/WhenTests.swift b/Tests/Cancel/WhenTests.swift new file mode 100644 index 000000000..5edeab944 --- /dev/null +++ b/Tests/Cancel/WhenTests.swift @@ -0,0 +1,348 @@ +import PromiseKit +import Dispatch +import XCTest + +class WhenTests: XCTestCase { + + func testEmpty() { + let e1 = expectation(description: "") + let promises: [CancellablePromise] = [] + when(fulfilled: promises).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + + let e2 = expectation(description: "") + when(resolved: promises).done { _ in + XCTFail() + e2.fulfill() + }.catch(policy: .allErrors) { + $0.isCancelled ? e2.fulfill() : XCTFail() + }.cancel() + + wait(for: [e1, e2], timeout: 1) + } + + func testInt() { + let e1 = expectation(description: "") + let p1 = Promise.value(1).cancellize() + let p2 = Promise.value(2).cancellize() + let p3 = Promise.value(3).cancellize() + let p4 = Promise.value(4).cancellize() + + when(fulfilled: [p1, p2, p3, p4]).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1, handler: nil) + } + + func testIntAlt() { + let e1 = expectation(description: "") + let p1 = Promise.value(1).cancellize() + let p2 = Promise.value(2).cancellize() + let p3 = Promise.value(3).cancellize() + let p4 = Promise.value(4).cancellize() + + when(fulfilled: p1, p2, p3, p4).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1, handler: nil) + } + + func testDoubleTupleSucceed() { + let e1 = expectation(description: "") + let p1 = Promise.value(1).cancellize() + let p2 = Promise.value("abc").cancellize() + cancellableWhen(fulfilled: p1, p2).done{ x, y in + XCTAssertEqual(x, 1) + XCTAssertEqual(y, "abc") + e1.fulfill() + }.silenceWarning() + waitForExpectations(timeout: 1, handler: nil) + } + + func testDoubleTupleCancel() { + let e1 = expectation(description: "") + let p1 = Promise.value(1).cancellize() + let p2 = Promise.value("abc").cancellize() + cancellableWhen(fulfilled: p1, p2).done{ _, _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1, handler: nil) + } + + func testTripleTuple() { + let e1 = expectation(description: "") + let p1 = Promise.value(1).cancellize() + let p2 = Promise.value("abc").cancellize() + let p3 = Promise.value(1.0).cancellize() + cancellableWhen(fulfilled: p1, p2, p3).done { _, _, _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1, handler: nil) + } + + func testQuadrupleTuple() { + let e1 = expectation(description: "") + let p1 = Promise.value(1).cancellize() + let p2 = Promise.value("abc").cancellize() + let p3 = Promise.value(1.0).cancellize() + let p4 = Promise.value(true).cancellize() + cancellableWhen(fulfilled: p1, p2, p3, p4).done { _, _, _, _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1, handler: nil) + } + + func testQuintupleTuple() { + let e1 = expectation(description: "") + let p1 = Promise.value(1).cancellize() + let p2 = Promise.value("abc").cancellize() + let p3 = Promise.value(1.0).cancellize() + let p4 = Promise.value(true).cancellize() + let p5 = Promise.value("a" as Character).cancellize() + cancellableWhen(fulfilled: p1, p2, p3, p4, p5).done { _, _, _, _, _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1, handler: nil) + } + + func testVoid() { + let e1 = expectation(description: "") + let p1 = Promise.value(1).cancellize().done { _ in } + let p2 = Promise.value(2).cancellize().done { _ in } + let p3 = Promise.value(3).cancellize().done { _ in } + let p4 = Promise.value(4).cancellize().done { _ in } + + when(fulfilled: p1, p2, p3, p4).done { + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testRejected() { + enum Error: Swift.Error { case dummy } + + let e1 = expectation(description: "") + let p1 = after(.milliseconds(100)).cancellize().map{ true } + let p2: CancellablePromise = after(.milliseconds(200)).cancellize().map{ throw Error.dummy } + let p3 = Promise.value(false).cancellize() + + cancellableWhen(fulfilled: p1, p2, p3).catch(policy: .allErrors) { + $0.isCancelled ? e1.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testProgress() { + let ex = expectation(description: "") + + XCTAssertNil(Progress.current()) + + let p1 = after(.milliseconds(10)).cancellize() + let p2 = after(.milliseconds(20)).cancellize() + let p3 = after(.milliseconds(30)).cancellize() + let p4 = after(.milliseconds(40)).cancellize() + + let progress = Progress(totalUnitCount: 1) + progress.becomeCurrent(withPendingUnitCount: 1) + + when(fulfilled: p1, p2, p3, p4).done { _ in + XCTAssertEqual(progress.completedUnitCount, 1) + ex.fulfill() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + progress.resignCurrent() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testProgressDoesNotExceed100PercentSucceed() { + let ex1 = expectation(description: "") + let ex2 = expectation(description: "") + + XCTAssertNil(Progress.current()) + + let p1 = after(.milliseconds(10)).cancellize() + let p2 = after(.milliseconds(20)).cancellize().done { throw NSError(domain: "a", code: 1, userInfo: nil) } + let p3 = after(.milliseconds(30)).cancellize() + let p4 = after(.milliseconds(40)).cancellize() + + let progress = Progress(totalUnitCount: 1) + progress.becomeCurrent(withPendingUnitCount: 1) + + let promise = when(fulfilled: p1, p2, p3, p4) + + progress.resignCurrent() + + promise.catch { _ in + ex2.fulfill() + } + + var x = 0 + func finally() { + x += 1 + if x == 4 { + XCTAssertLessThanOrEqual(1, progress.fractionCompleted) + XCTAssertEqual(progress.completedUnitCount, 1) + ex1.fulfill() + } + } + + let q = DispatchQueue.main + p1.done(on: q, finally).silenceWarning() + p2.ensure(on: q, finally).silenceWarning() + p3.done(on: q, finally).silenceWarning() + p4.done(on: q, finally).silenceWarning() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testProgressDoesNotExceed100PercentCancel() { + let ex1 = expectation(description: "") + let ex2 = expectation(description: "") + let ex3 = expectation(description: "") + + XCTAssertNil(Progress.current()) + + let p1 = after(.milliseconds(10)).cancellize() + let p2 = after(.milliseconds(20)).cancellize().done { throw NSError(domain: "a", code: 1, userInfo: nil) } + let p3 = after(.milliseconds(30)).cancellize() + let p4 = after(.milliseconds(40)).cancellize() + + let progress = Progress(totalUnitCount: 1) + progress.becomeCurrent(withPendingUnitCount: 1) + + let promise = when(fulfilled: p1, p2, p3, p4) + + progress.resignCurrent() + + promise.catch(policy: .allErrors) { + $0.isCancelled ? ex2.fulfill() : XCTFail() + } + + promise.cancel() + + func finally() { + XCTFail() + } + + func finallyEnsure() { + ex3.fulfill() + } + + var x = 0 + func catchall(err: Error) { + XCTAssert(err.isCancelled) + x += 1 + if x == 4 { + XCTAssertLessThanOrEqual(1, progress.fractionCompleted) + XCTAssertEqual(progress.completedUnitCount, 1) + ex1.fulfill() + } + } + + let q = DispatchQueue.main + p1.done(on: q, finally).catch(policy: .allErrors, catchall) + p2.ensure(on: q, finallyEnsure).catch(policy: .allErrors, catchall) + p3.done(on: q, finally).catch(policy: .allErrors, catchall) + p4.done(on: q, finally).catch(policy: .allErrors, catchall) + + waitForExpectations(timeout: 1, handler: nil) + } + + func testUnhandledErrorHandlerDoesNotFire() { + enum Error: Swift.Error { + case test + } + + let ex = expectation(description: "") + let p1 = CancellablePromise(error: Error.test) + let p2 = after(.milliseconds(100)).cancellize() + cancellableWhen(fulfilled: p1, p2).done{ _ in XCTFail() }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testUnhandledErrorHandlerDoesNotFireForStragglers() { + enum Error: Swift.Error { + case test + case straggler + } + + let ex1 = expectation(description: "") + let ex2 = expectation(description: "") + let ex3 = expectation(description: "") + + let p1 = CancellablePromise(error: Error.test) + let p2 = after(.milliseconds(100)).cancellize().done { throw Error.straggler } + let p3 = after(.milliseconds(200)).cancellize().done { throw Error.straggler } + + cancellableWhen(fulfilled: p1, p2, p3).catch(policy: .allErrors) { + $0.isCancelled ? ex1.fulfill() : XCTFail() + }.cancel() + + p2.ensure { after(.milliseconds(100)).done(ex2.fulfill) }.silenceWarning() + p3.ensure { after(.milliseconds(100)).done(ex3.fulfill) }.silenceWarning() + + waitForExpectations(timeout: 1, handler: nil) + } + + func testAllSealedRejectedFirstOneRejects() { + enum Error: Swift.Error { + case test1 + case test2 + case test3 + } + + let ex = expectation(description: "") + let p1 = CancellablePromise(error: Error.test1) + let p2 = CancellablePromise(error: Error.test2) + let p3 = CancellablePromise(error: Error.test3) + + when(fulfilled: p1, p2, p3).catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + + waitForExpectations(timeout: 1) + } + + func testGuaranteeWhen() { + let ex1 = expectation(description: "") + when(resolved: Guarantee().cancellize(), Guarantee().cancellize()).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex1.fulfill() : XCTFail() + }.cancel() + + let ex2 = expectation(description: "") + when(resolved: [Guarantee().cancellize(), Guarantee().cancellize()]).done { _ in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex2.fulfill() : XCTFail() + }.cancel() + + wait(for: [ex1, ex2], timeout: 10) + } +} diff --git a/Tests/Cancel/XCTestManifests.swift b/Tests/Cancel/XCTestManifests.swift new file mode 100644 index 000000000..cf90f8da5 --- /dev/null +++ b/Tests/Cancel/XCTestManifests.swift @@ -0,0 +1,380 @@ +#if !canImport(ObjectiveC) +import XCTest + +extension AfterTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__AfterTests = [ + ("testCancelForGuarantee_Done", testCancelForGuarantee_Done), + ("testCancelForPromise_Done", testCancelForPromise_Done), + ("testCancellableAfter", testCancellableAfter), + ("testNegative", testNegative), + ("testPositive", testPositive), + ("testZero", testZero), + ] +} + +extension CancelChain { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CancelChain = [ + ("testCancelChainPAD", testCancelChainPAD), + ("testCancelChainPB", testCancelChainPB), + ("testCancelChainPC", testCancelChainPC), + ("testCancelChainSuccess", testCancelChainSuccess), + ] +} + +extension CancellableDefaultDispatchQueueTest { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CancellableDefaultDispatchQueueTest = [ + ("testOverrodeDefaultAlwaysQueue", testOverrodeDefaultAlwaysQueue), + ("testOverrodeDefaultCatchQueue", testOverrodeDefaultCatchQueue), + ("testOverrodeDefaultThenQueue", testOverrodeDefaultThenQueue), + ] +} + +extension CancellableErrorTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CancellableErrorTests = [ + ("testCustomDebugStringConvertible", testCustomDebugStringConvertible), + ("testCustomStringConvertible", testCustomStringConvertible), + ] +} + +extension CancellablePromiseTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CancellablePromiseTests = [ + ("testBridge", testBridge), + ("testCancel", testCancel), + ("testCancellablePromiseEmbeddedInStandardPromiseChain", testCancellablePromiseEmbeddedInStandardPromiseChain), + ("testChain", testChain), + ("testFirstly", testFirstly), + ("testFirstlyWithPromise", testFirstlyWithPromise), + ("testReturnTypeForAMultiLineClosureIsNotExplicitlyStated", testReturnTypeForAMultiLineClosureIsNotExplicitlyStated), + ("testThenMapCancel", testThenMapCancel), + ("testThenMapSuccess", testThenMapSuccess), + ("testTryingToCancelAStandardPromiseChain", testTryingToCancelAStandardPromiseChain), + ] +} + +extension CancellationTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CancellationTests = [ + ("testCancellation", testCancellation), + ("testFoundationBridging1", testFoundationBridging1), + ("testFoundationBridging2", testFoundationBridging2), + ("testIsCancelled", testIsCancelled), + ("testRecoverWithCancellation", testRecoverWithCancellation), + ("testThrowCancellableErrorThatIsNotCancelled", testThrowCancellableErrorThatIsNotCancelled), + ] +} + +extension CatchableTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__CatchableTests = [ + ("test__cancellable_conditional_recover__fulfilled_path", test__cancellable_conditional_recover__fulfilled_path), + ("test__conditional_recover", test__conditional_recover), + ("test__conditional_recover__fulfilled_path", test__conditional_recover__fulfilled_path), + ("test__conditional_recover__ignores_cancellation_but_fed_cancellation", test__conditional_recover__ignores_cancellation_but_fed_cancellation), + ("test__conditional_recover__no_recover", test__conditional_recover__no_recover), + ("test__full_recover", test__full_recover), + ("test__full_recover__fulfilled_path", test__full_recover__fulfilled_path), + ("test__void_specialized_conditional_recover", test__void_specialized_conditional_recover), + ("test__void_specialized_conditional_recover__fulfilled_path", test__void_specialized_conditional_recover__fulfilled_path), + ("test__void_specialized_conditional_recover__ignores_cancellation_but_fed_cancellation", test__void_specialized_conditional_recover__ignores_cancellation_but_fed_cancellation), + ("test__void_specialized_conditional_recover__no_recover", test__void_specialized_conditional_recover__no_recover), + ("test__void_specialized_full_recover", test__void_specialized_full_recover), + ("test__void_specialized_full_recover__fulfilled_path", test__void_specialized_full_recover__fulfilled_path), + ("testCancellableFinalizerHelpers", testCancellableFinalizerHelpers), + ("testCancellableRecoverFromError", testCancellableRecoverFromError), + ("testCauterize", testCauterize), + ("testEnsureThen_Error", testEnsureThen_Error), + ("testEnsureThen_Value", testEnsureThen_Value), + ("testEnsureThen_Value_NotCancelled", testEnsureThen_Value_NotCancelled), + ("testFinally", testFinally), + ] +} + +extension DispatcherTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__DispatcherTests = [ + ("testDispatcherExtensionCanThrowInBody", testDispatcherExtensionCanThrowInBody), + ("testDispatcherExtensionReturnsGuarantee", testDispatcherExtensionReturnsGuarantee), + ("testDispatcherWithThrow", testDispatcherWithThrow), + ("testDispatchQueueSelection", testDispatchQueueSelection), + ("testMapValues", testMapValues), + ("testRecover", testRecover), + ] +} + +extension GuaranteeTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__GuaranteeTests = [ + ("testCancellable", testCancellable), + ("testInit", testInit), + ("testSetCancellable", testSetCancellable), + ("testThenMap", testThenMap), + ("testWait", testWait), + ] +} + +extension HangTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__HangTests = [ + ("test", test), + ("testError", testError), + ] +} + +extension JoinTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__JoinTests = [ + ("testCancelledAfterAllResolve", testCancelledAfterAllResolve), + ("testFulfilledAfterAllResolve", testFulfilledAfterAllResolve), + ("testImmediates", testImmediates), + ] +} + +extension PromiseTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__PromiseTests = [ + ("testBodyThrowsError", testBodyThrowsError), + ("testCancellable", testCancellable), + ("testCanMakeVoidPromise", testCanMakeVoidPromise), + ("testCannotFulfillWithError", testCannotFulfillWithError), + ("testCustomStringConvertible", testCustomStringConvertible), + ("testDispatchQueueAsyncExtensionCanThrowInBody", testDispatchQueueAsyncExtensionCanThrowInBody), + ("testDispatchQueueAsyncExtensionReturnsPromise", testDispatchQueueAsyncExtensionReturnsPromise), + ("testInitCancellable", testInitCancellable), + ("testInitVoidCancellable", testInitVoidCancellable), + ("testIsFulfilled", testIsFulfilled), + ("testIsPending", testIsPending), + ("testIsRejected", testIsRejected), + ("testIsResolved", testIsResolved), + ("testPipeForResolved", testPipeForResolved), + ("testSetCancellable", testSetCancellable), + ("testThrowInFirstly", testThrowInFirstly), + ("testThrowInInitializer", testThrowInInitializer), + ("testWait", testWait), + ] +} + +extension RaceTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__RaceTests = [ + ("test1", test1), + ("test1Array", test1Array), + ("test2", test2), + ("test2Array", test2Array), + ("testCancelInner", testCancelInner), + ("testEmptyArray", testEmptyArray), + ("testReject", testReject), + ] +} + +extension RegressionTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__RegressionTests = [ + ("testReturningPreviousPromiseWorks", testReturningPreviousPromiseWorks), + ] +} + +extension StressTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__StressTests = [ + ("testCancelContextConcurrentAppend", testCancelContextConcurrentAppend), + ("testCancelContextConcurrentCancel", testCancelContextConcurrentCancel), + ("testCancelContextConcurrentReadWrite", testCancelContextConcurrentReadWrite), + ("testThenDataRace", testThenDataRace), + ("testThensAreSequentialForLongTime", testThensAreSequentialForLongTime), + ("testZalgoDataRace", testZalgoDataRace), + ] +} + +extension ThenableTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ThenableTests = [ + ("testBarrier", testBarrier), + ("testCompactMap", testCompactMap), + ("testCompactMapThrows", testCompactMapThrows), + ("testCompactMapValues", testCompactMapValues), + ("testDispatchFlagsSyntax", testDispatchFlagsSyntax), + ("testFirstValueForEmpty", testFirstValueForEmpty), + ("testGet", testGet), + ("testLastValueForEmpty", testLastValueForEmpty), + ("testPMKErrorCompactMap", testPMKErrorCompactMap), + ("testRejectedPromiseCompactMap", testRejectedPromiseCompactMap), + ("testThenFlatMap", testThenFlatMap), + ("testThenMap", testThenMap), + ("testThenOffRejected", testThenOffRejected), + ] +} + +extension TimeoutTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__TimeoutTests = [ + ("testCancelBeforeTimeout", testCancelBeforeTimeout), + ("testCancelRaceBeforeTimeout", testCancelRaceBeforeTimeout), + ("testDoubleTimeout", testDoubleTimeout), + ("testMixTypes", testMixTypes), + ("testNoTimeout", testNoTimeout), + ("testReset", testReset), + ("testTimeout", testTimeout), + ] +} + +extension ValueTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ValueTests = [ + ("testCancelForPromise_Then", testCancelForPromise_Then), + ("testCancelForPromise_ThenDone", testCancelForPromise_ThenDone), + ("testFirstlyThenValueDone", testFirstlyThenValueDone), + ("testFirstlyValueDifferentContextDone", testFirstlyValueDifferentContextDone), + ("testFirstlyValueDone", testFirstlyValueDone), + ("testFirstlyValueDoneDifferentContext", testFirstlyValueDoneDifferentContext), + ("testValueContext", testValueContext), + ("testValueDone", testValueDone), + ("testValueThen", testValueThen), + ] +} + +extension WhenConcurrentTestCase_Swift { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__WhenConcurrentTestCase_Swift = [ + ("testStopsDequeueingOnceRejected", testStopsDequeueingOnceRejected), + ("testWhenCancel", testWhenCancel), + ("testWhenConcurrencyCancel", testWhenConcurrencyCancel), + ("testWhenConcurrencyLessThanZero", testWhenConcurrencyLessThanZero), + ("testWhenConcurrencySucceed", testWhenConcurrencySucceed), + ("testWhenEmptyGeneratorCancel", testWhenEmptyGeneratorCancel), + ("testWhenEmptyGeneratorSucceed", testWhenEmptyGeneratorSucceed), + ("testWhenGeneratorErrorCancel", testWhenGeneratorErrorCancel), + ("testWhenGeneratorErrorSucceed", testWhenGeneratorErrorSucceed), + ("testWhenSucceed", testWhenSucceed), + ] +} + +extension WhenTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__WhenTests = [ + ("testAllSealedRejectedFirstOneRejects", testAllSealedRejectedFirstOneRejects), + ("testDoubleTupleCancel", testDoubleTupleCancel), + ("testDoubleTupleSucceed", testDoubleTupleSucceed), + ("testEmpty", testEmpty), + ("testGuaranteeWhen", testGuaranteeWhen), + ("testInt", testInt), + ("testIntAlt", testIntAlt), + ("testProgress", testProgress), + ("testProgressDoesNotExceed100PercentCancel", testProgressDoesNotExceed100PercentCancel), + ("testProgressDoesNotExceed100PercentSucceed", testProgressDoesNotExceed100PercentSucceed), + ("testQuadrupleTuple", testQuadrupleTuple), + ("testQuintupleTuple", testQuintupleTuple), + ("testRejected", testRejected), + ("testTripleTuple", testTripleTuple), + ("testUnhandledErrorHandlerDoesNotFire", testUnhandledErrorHandlerDoesNotFire), + ("testUnhandledErrorHandlerDoesNotFireForStragglers", testUnhandledErrorHandlerDoesNotFireForStragglers), + ("testVoid", testVoid), + ] +} + +extension WrapTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__WrapTests = [ + ("testError", testError), + ("testErrorCancellableKitten", testErrorCancellableKitten), + ("testErrorNoDelay", testErrorNoDelay), + ("testInvalidCallingConvention", testInvalidCallingConvention), + ("testInvalidCallingConventionCancellableKitten", testInvalidCallingConventionCancellableKitten), + ("testInvertedCallingConvention", testInvertedCallingConvention), + ("testInvertedCallingConventionCancellableKitten", testInvertedCallingConventionCancellableKitten), + ("testIsFulfilled", testIsFulfilled), + ("testNonOptionalFirstParameter", testNonOptionalFirstParameter), + ("testNonOptionalFirstParameterCancellableKitten", testNonOptionalFirstParameterCancellableKitten), + ("testPendingPromiseDeallocated", testPendingPromiseDeallocated), + ("testSuccess", testSuccess), + ("testVoidCompletionValue", testVoidCompletionValue), + ("testVoidCompletionValueCancellableKitten", testVoidCompletionValueCancellableKitten), + ] +} + +extension ZalgoTests { + // DO NOT MODIFY: This is autogenerated, use: + // `swift test --generate-linuxmain` + // to regenerate. + static let __allTests__ZalgoTests = [ + ("test1", test1), + ("test2", test2), + ("test3Cancel", test3Cancel), + ("test3Succeed", test3Succeed), + ("test4", test4), + ] +} + +public func __allTests() -> [XCTestCaseEntry] { + return [ + testCase(AfterTests.__allTests__AfterTests), + testCase(CancelChain.__allTests__CancelChain), + testCase(CancellableDefaultDispatchQueueTest.__allTests__CancellableDefaultDispatchQueueTest), + testCase(CancellableErrorTests.__allTests__CancellableErrorTests), + testCase(CancellablePromiseTests.__allTests__CancellablePromiseTests), + testCase(CancellationTests.__allTests__CancellationTests), + testCase(CatchableTests.__allTests__CatchableTests), + testCase(DispatcherTests.__allTests__DispatcherTests), + testCase(GuaranteeTests.__allTests__GuaranteeTests), + testCase(HangTests.__allTests__HangTests), + testCase(JoinTests.__allTests__JoinTests), + testCase(PromiseTests.__allTests__PromiseTests), + testCase(RaceTests.__allTests__RaceTests), + testCase(RegressionTests.__allTests__RegressionTests), + testCase(StressTests.__allTests__StressTests), + testCase(ThenableTests.__allTests__ThenableTests), + testCase(TimeoutTests.__allTests__TimeoutTests), + testCase(ValueTests.__allTests__ValueTests), + testCase(WhenConcurrentTestCase_Swift.__allTests__WhenConcurrentTestCase_Swift), + testCase(WhenTests.__allTests__WhenTests), + testCase(WrapTests.__allTests__WrapTests), + testCase(ZalgoTests.__allTests__ZalgoTests), + ] +} +#endif diff --git a/Tests/Cancel/ZalgoTests.swift b/Tests/Cancel/ZalgoTests.swift new file mode 100644 index 000000000..11fe3ca9b --- /dev/null +++ b/Tests/Cancel/ZalgoTests.swift @@ -0,0 +1,89 @@ +import XCTest +import PromiseKit + +class ZalgoTests: XCTestCase { + func test1() { + var resolved = false + Promise.value(1).cancellize().done(on: nil) { _ in + resolved = true + }.catch(policy: .allErrors) { _ in + resolved = false + }.cancel() + XCTAssertTrue(resolved) + } + + func test2() { + let p1 = Promise.value(1).cancellize().map(on: nil) { _ in + return 2 + } + p1.cancel() + XCTAssertEqual(p1.value!, 2) + + var x = 0 + + let ex = expectation(description: "") + let (p2, seal) = CancellablePromise.pending() + p2.cancel() + p2.done(on: nil) { _ in + x = 1 + }.catch(policy: .allErrors) { _ in + x = 2 + ex.fulfill() + } + XCTAssertEqual(x, 0) + + seal.fulfill(1) + waitForExpectations(timeout: 1) + XCTAssertEqual(x, 2) + } + + // returning a pending promise from its own zalgo’d then handler doesn’t hang + func test3Succeed() { + let ex = (expectation(description: ""), expectation(description: "")) + + var p1: CancellablePromise! + p1 = after(.milliseconds(100)).cancellize().then(on: nil) { _ -> CancellablePromise in + p1.cancel() + ex.0.fulfill() + return p1 + } + + p1.catch(policy: .allErrors) { err in + defer{ ex.1.fulfill() } + guard case PMKError.returnedSelf = err else { return XCTFail() } + } + + waitForExpectations(timeout: 1) + } + + // returning a pending promise from its own zalgo’d then handler doesn’t hang + func test3Cancel() { + let ex = expectation(description: "") + + var p1: CancellablePromise! + p1 = after(.milliseconds(100)).cancellize().then(on: nil) { _ -> CancellablePromise in + XCTFail() + return p1 + } + p1.cancel() + + p1.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + + waitForExpectations(timeout: 1) + } + + // return a sealed promise from its own zalgo’d then handler doesn’t hang + func test4() { + let ex = expectation(description: "") + let p1 = Promise.value(1).cancellize() + p1.then(on: nil) { _ -> CancellablePromise in + ex.fulfill() + return p1 + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + }.cancel() + waitForExpectations(timeout: 1) + } +} diff --git a/Tests/Core/DispatcherTests.swift b/Tests/Core/DispatcherTests.swift index c5551afa2..b4288660b 100644 --- a/Tests/Core/DispatcherTests.swift +++ b/Tests/Core/DispatcherTests.swift @@ -94,6 +94,8 @@ class DispatcherTests: XCTestCase { XCTAssertNotNil(queueID) XCTAssertEqual(queueID!, 100) return x + 10 + }.get(on: .global(qos: .background), flags: .barrier) { _ in + }.tap(on: .global(qos: .background), flags: .barrier) { _ in }.then(on: .main, flags: []) { (x: Int) -> Promise in XCTAssertEqual(x, 52) let queueID = DispatchQueue.getSpecific(key: queueIDKey) @@ -124,6 +126,81 @@ class DispatcherTests: XCTestCase { } + func testMapValues() { + let ex1 = expectation(description: "DispatchQueue MapValues compatibility") + Promise.value([42, 52]).mapValues(on: .global(qos: .background), flags: .barrier) { + $0 + 10 + }.compactMap(on: .global(qos: .background), flags: .barrier) { + $0 + }.flatMapValues(on: .global(qos: .background), flags: .barrier) { + [$0 + 10] + }.compactMapValues(on: .global(qos: .background), flags: .barrier) { + $0 + 10 + }.thenMap(on: .global(qos: .background), flags: .barrier) { + Promise.value($0 + 10) + }.thenFlatMap(on: .global(qos: .background), flags: .barrier) { + Promise.value([$0 + 10]) + }.filterValues(on: .global(qos: .background), flags: .barrier) { _ in + true + }.sortedValues(on: .global(qos: .background), flags: .barrier).firstValue(on: .global(qos: .background), flags: .barrier) { _ in + true + }.done(on: .global(qos: .background), flags: .barrier) { + XCTAssertEqual($0, 92) + ex1.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier) { _ in + XCTFail() + } + + let ex2 = expectation(description: "DispatchQueue firstValue property") + Promise.value([42, 52]).firstValue.done(on: .global(qos: .background), flags: .barrier) { + XCTAssertEqual($0, 42) + ex2.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier, policy: .allErrors) { _ in + XCTFail() + } + + let ex3 = expectation(description: "DispatchQueue lastValue property") + Promise.value([42, 52]).lastValue.done(on: .global(qos: .background), flags: .barrier) { + XCTAssertEqual($0, 52) + ex3.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier, policy: .allErrors) { _ in + XCTFail() + } + + waitForExpectations(timeout: 1) + } + + func testRecover() { + let ex1 = expectation(description: "DispatchQueue CatchMixin compatibility") + Promise.value(42).recover(on: .global(qos: .background), flags: .barrier) { _ in + return Promise.value(42) + }.ensure(on: .global(qos: .background), flags: .barrier) { + }.ensureThen(on: .global(qos: .background), flags: .barrier) { + return after(seconds: 0.0) + }.recover(on: .global(qos: .background), flags: .barrier) { _ in + return Promise.value(42) + }.done(on: .global(qos: .background), flags: .barrier) { + XCTAssertEqual($0, 42) + ex1.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier) { _ in + XCTFail() + } + + let ex2 = expectation(description: "DispatchQueue CatchMixin Void recover") + firstly { + Promise.value(42).asVoid() + }.recover(on: .global(qos: .background), flags: .barrier) { _ in + throw PMKError.emptySequence + }.recover(on: .global(qos: .background), flags: .barrier) { _ in + }.done { + ex2.fulfill() + }.catch(on: .global(qos: .background), flags: .barrier) { _ in + XCTFail() + } + + waitForExpectations(timeout: 1) + } + @available(macOS 10.10, iOS 2.0, tvOS 10.0, watchOS 2.0, *) func testDispatcherExtensionReturnsGuarantee() { let ex = expectation(description: "Dispatcher.promise") diff --git a/Tests/Core/RaceTests.swift b/Tests/Core/RaceTests.swift index c3676a11e..b0af141f9 100644 --- a/Tests/Core/RaceTests.swift +++ b/Tests/Core/RaceTests.swift @@ -48,4 +48,14 @@ class RaceTests: XCTestCase { } wait(for: [ex], timeout: 10) } + + func testReject() { + let ex = expectation(description: "") + race(Promise(error: PMKError.timedOut), after(.milliseconds(10)).map{ 2 }).done { index in + XCTFail() + }.catch(policy: .allErrors) { + $0.isCancelled ? ex.fulfill() : XCTFail() + } + waitForExpectations(timeout: 1, handler: nil) + } } diff --git a/Tests/Core/XCTestManifests.swift b/Tests/Core/XCTestManifests.swift index 085fafd03..6fce488bb 100644 --- a/Tests/Core/XCTestManifests.swift +++ b/Tests/Core/XCTestManifests.swift @@ -69,7 +69,9 @@ extension DispatcherTests { ("testDispatcherExtensionReturnsGuarantee", testDispatcherExtensionReturnsGuarantee), ("testDispatcherWithThrow", testDispatcherWithThrow), ("testDispatchQueueSelection", testDispatchQueueSelection), + ("testMapValues", testMapValues), ("testPMKDefaultIdentity", testPMKDefaultIdentity), + ("testRecover", testRecover), ] } @@ -175,6 +177,7 @@ extension RaceTests { ("test2", test2), ("test2Array", test2Array), ("testEmptyArray", testEmptyArray), + ("testReject", testReject), ] } diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 3ccd10feb..ba82cd3fd 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -2,11 +2,13 @@ import XCTest import A__js import A__swift +import Cancel import Core var tests = [XCTestCaseEntry]() tests += A__js.__allTests() tests += A__swift.__allTests() +tests += Cancel.__allTests() tests += Core.__allTests() XCTMain(tests)