From 8fee6d6d91ccc77158e597f86c4e10b6bea4eefd Mon Sep 17 00:00:00 2001 From: tcldr Date: Mon, 24 Jun 2019 18:05:38 +0200 Subject: [PATCH] Release 0.1 Initial release --- .gitignore | 86 +++- .../contents.xcworkspacedata | 2 +- .../xcshareddata/IDETemplateMacros.plist | 30 ++ .../xcschemes/Entwine-Package.xcscheme | 129 ++++++ .../xcshareddata/xcschemes/Entwine.xcscheme | 67 +++ Assets/Entwine/README.md | 104 +++++ Assets/EntwineTest/README.md | 242 ++++++++++ LICENSE | 24 + Package.swift | 31 +- README.md | 117 ++++- .../DataStructures/LinkedListQueue.swift | 109 +++++ .../DataStructures/LinkedListStack.swift | 132 ++++++ .../Common/DataStructures/PriorityQueue.swift | 190 ++++++++ Sources/Common/Utilities/SinkQueue.swift | 86 ++++ .../DataStructures/LinkedListQueue.swift | 1 + .../DataStructures/LinkedListStack.swift | 1 + .../Common/DataStructures/PriorityQueue.swift | 1 + .../Entwine/Common/Utilities/SinkQueue.swift | 1 + Sources/Entwine/Operators/Dematerialize.swift | 270 +++++++++++ Sources/Entwine/Operators/Materialize.swift | 145 ++++++ Sources/Entwine/Operators/ReplaySubject.swift | 173 ++++++++ Sources/Entwine/Operators/ShareReplay.swift | 42 ++ .../Entwine/Operators/WithLatestFrom.swift | 156 +++++++ Sources/Entwine/Publishers/Factory.swift | 155 +++++++ .../Schedulers/TrampolineScheduler.swift | 104 +++++ Sources/Entwine/Signal.swift | 98 ++++ .../DataStructures/LinkedListQueue.swift | 1 + .../DataStructures/LinkedListStack.swift | 1 + .../Common/DataStructures/PriorityQueue.swift | 1 + .../Common/Utilities/SinkQueue.swift | 1 + .../Signal+CustomDebugStringConvertible.swift | 40 ++ Sources/EntwineTest/TestEvent.swift | 49 ++ .../TestScheduler/TestScheduler.swift | 232 ++++++++++ .../TestScheduler/VirtualTime.swift | 117 +++++ .../TestScheduler/VirtualTimeInterval.swift | 157 +++++++ Sources/EntwineTest/TestSequence.swift | 107 +++++ .../TestablePublisher/TestablePublisher.swift | 103 +++++ .../TestableSubscriber/DemandLedger.swift | 156 +++++++ .../TestableSubscriber.swift | 181 ++++++++ Sources/TestScheduler/TestScheduler.swift | 3 - .../EntwineTestTests/TestSchedulerTests.swift | 220 +++++++++ .../TestablePublisherTests.swift | 80 ++++ .../TestableSubscriberTests.swift | 296 +++++++++++++ .../XCTestManifests.swift | 2 + Tests/EntwineTests/DematerializeTests.swift | 105 +++++ Tests/EntwineTests/FactoryTests.swift | 107 +++++ Tests/EntwineTests/MaterializeTests.swift | 89 ++++ Tests/EntwineTests/ReplaySubjectTests.swift | 419 ++++++++++++++++++ Tests/EntwineTests/ShareReplayTests.swift | 232 ++++++++++ .../TrampolineSchedulerTests.swift | 108 +++++ Tests/EntwineTests/WithLatestFromTests.swift | 144 ++++++ Tests/LinuxMain.swift | 7 - .../TestSchedulerTests.swift | 15 - 53 files changed, 5424 insertions(+), 45 deletions(-) create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Entwine-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Entwine.xcscheme create mode 100644 Assets/Entwine/README.md create mode 100644 Assets/EntwineTest/README.md create mode 100644 LICENSE create mode 100644 Sources/Common/DataStructures/LinkedListQueue.swift create mode 100644 Sources/Common/DataStructures/LinkedListStack.swift create mode 100644 Sources/Common/DataStructures/PriorityQueue.swift create mode 100644 Sources/Common/Utilities/SinkQueue.swift create mode 120000 Sources/Entwine/Common/DataStructures/LinkedListQueue.swift create mode 120000 Sources/Entwine/Common/DataStructures/LinkedListStack.swift create mode 120000 Sources/Entwine/Common/DataStructures/PriorityQueue.swift create mode 120000 Sources/Entwine/Common/Utilities/SinkQueue.swift create mode 100644 Sources/Entwine/Operators/Dematerialize.swift create mode 100644 Sources/Entwine/Operators/Materialize.swift create mode 100644 Sources/Entwine/Operators/ReplaySubject.swift create mode 100644 Sources/Entwine/Operators/ShareReplay.swift create mode 100644 Sources/Entwine/Operators/WithLatestFrom.swift create mode 100644 Sources/Entwine/Publishers/Factory.swift create mode 100644 Sources/Entwine/Schedulers/TrampolineScheduler.swift create mode 100644 Sources/Entwine/Signal.swift create mode 120000 Sources/EntwineTest/Common/DataStructures/LinkedListQueue.swift create mode 120000 Sources/EntwineTest/Common/DataStructures/LinkedListStack.swift create mode 120000 Sources/EntwineTest/Common/DataStructures/PriorityQueue.swift create mode 120000 Sources/EntwineTest/Common/Utilities/SinkQueue.swift create mode 100644 Sources/EntwineTest/Signal+CustomDebugStringConvertible.swift create mode 100644 Sources/EntwineTest/TestEvent.swift create mode 100644 Sources/EntwineTest/TestScheduler/TestScheduler.swift create mode 100644 Sources/EntwineTest/TestScheduler/VirtualTime.swift create mode 100644 Sources/EntwineTest/TestScheduler/VirtualTimeInterval.swift create mode 100644 Sources/EntwineTest/TestSequence.swift create mode 100644 Sources/EntwineTest/TestablePublisher/TestablePublisher.swift create mode 100644 Sources/EntwineTest/TestableSubscriber/DemandLedger.swift create mode 100644 Sources/EntwineTest/TestableSubscriber/TestableSubscriber.swift delete mode 100644 Sources/TestScheduler/TestScheduler.swift create mode 100644 Tests/EntwineTestTests/TestSchedulerTests.swift create mode 100644 Tests/EntwineTestTests/TestablePublisherTests.swift create mode 100644 Tests/EntwineTestTests/TestableSubscriberTests.swift rename Tests/{TestSchedulerTests => EntwineTestTests}/XCTestManifests.swift (61%) create mode 100644 Tests/EntwineTests/DematerializeTests.swift create mode 100644 Tests/EntwineTests/FactoryTests.swift create mode 100644 Tests/EntwineTests/MaterializeTests.swift create mode 100644 Tests/EntwineTests/ReplaySubjectTests.swift create mode 100644 Tests/EntwineTests/ShareReplayTests.swift create mode 100644 Tests/EntwineTests/TrampolineSchedulerTests.swift create mode 100644 Tests/EntwineTests/WithLatestFromTests.swift delete mode 100644 Tests/LinuxMain.swift delete mode 100644 Tests/TestSchedulerTests/TestSchedulerTests.swift diff --git a/.gitignore b/.gitignore index 02c0875..2b5f284 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,82 @@ -.DS_Store -/.build -/Packages -/*.xcodeproj +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ \ No newline at end of file diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata index 706eede..919434a 100644 --- a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -2,6 +2,6 @@ + location = "self:"> diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 0000000..3e25acb --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,30 @@ + + + + + FILEHEADER + +// ___WORKSPACENAME___ +// https://github.com/tcldr/___WORKSPACENAME___ +// +// Copyright © ___YEAR___ Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Entwine-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Entwine-Package.xcscheme new file mode 100644 index 0000000..f9523c4 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Entwine-Package.xcscheme @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Entwine.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Entwine.xcscheme new file mode 100644 index 0000000..6975ae6 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Entwine.xcscheme @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Assets/Entwine/README.md b/Assets/Entwine/README.md new file mode 100644 index 0000000..d9402ad --- /dev/null +++ b/Assets/Entwine/README.md @@ -0,0 +1,104 @@ + +# Entwine Utilities + +Part of [Entwine](https://github.com/tcldr/Entwine) – A collection of accessories for [Apple's Combine Framework](https://developer.apple.com/documentation/combine). + +--- + +### CONTENTS +- [About](#about) +- [Getting Started](#getting-started) +- [Installation](#installation) +- [Documentation](#documentation) +- [Copyright and License](#copyright-and-license) + +--- + +### ABOUT + +_Entwine Utilities_ are a collection of operators, tools and extensions to make working with _Combine_ even more productive. + +- The `ReplaySubject` makes it simple for subscribers to receive the most recent values immediately upon subscription. +- The `withLatest(from:)` operator enables state to be taken alongside UI events. +- `Publishers.Factory` makes creating publishers fast and simple – they can even be created inline! +- `CancellableBag` helps to gather all your cancellable in a single place and helps to make your publisher chain declarations even clearer. + +be sure to checkout the [documentation](http://tcldr.github.io/Entwine/EntwineDocs) for the full list of operators and utilities. + +--- + +### GETTING STARTED + +Ensure to `import Entwine` in each file you wish to the utilities with. + +The operators can then be used as part of your usual publisher chain declaration: + +```swift + +import Combine +import Entwine + +class MyClass { + + let myEntwineCancellationBag = CancellationBag() + + ... + + func printLatestColorOnClicks() { + let clicks: AnyPublisher = SomeClickPublisher.shared + let color: AnyPublisher = SomeColorSource.shared + + clicks.withLatest(from: color) + .sink { + print("clicked when the latest color was: \($0)") + } + .cancelled(by: myEntwineCancellationBag) + } +} + +``` + +Each operator, subject and utility is documented with examples. check out the [full documentation.](https://tcldr.github.io/Enwtine/EntwineDocs) + +--- + +### INSTALLATION +### As part of another Swift Package: +1. Include it in your `Package.swift` file as both a dependency and a dependency of your target. + +```swift +import PackageDescription + +let package = Package( + ... + dependencies: [ + .package(url: "http://github.com/tcldr/Entwine.git", .upToNextMajor(from: "0.1.0")), + ], + ... + targets: [.target(name: "MyTarget", dependencies: ["Entwine"]), + ] +) +``` + +2. Then run `swift package update` from the root directory of your SPM project. If you're using Xcode 11 to edit your SPM project this should happen automatically. + +### As part of an Xcode 11 or greater project: +1. Select the `File -> Swift Packages -> Add package dependency...` menu item. +2. Enter the repository url `https://github.com/tcldr/Entwine` and tap next. +3. Select 'version, 'up to next major', enter `0.1.0`, hit next. +4. Select the _Entwine_ library and specify the target you wish to use it with. + + +--- + +### DOCUMENTATION +Full documentation for _Entwine_ can be found at [http://tcldr.github.io/Entwine/EntwineDocs](http://tcldr.github.io/Entwine/EntwineDocs). + +--- + +### COPYRIGHT AND LICENSE +Copyright 2019 © Tristan Celder + +_Entwine_ is made available under the [MIT License](http://github.com/tcldr/Entwine/blob/master/LICENSE) + +--- \ No newline at end of file diff --git a/Assets/EntwineTest/README.md b/Assets/EntwineTest/README.md new file mode 100644 index 0000000..03cddb6 --- /dev/null +++ b/Assets/EntwineTest/README.md @@ -0,0 +1,242 @@ + +# Entwine Test + +Part of [Entwine](https://github.com/tcldr/Entwine) – A collection of accessories for [Apple's Combine Framework](https://developer.apple.com/documentation/combine). + +--- + +### CONTENTS +- [About _EntwineTest_](#about-entwinetest) +- [Getting Started](#getting-started) + - [TestScheduler](#testscheduler) + - [TestablePublisher](#testablepublisher) + - [TestableSubscriber](#testablesubscriber) + - [Putting it all together](#putting-it-all-together) +- [Installation](#installation) +- [Documentation](#documentation) +- [Acknowledgements](#acknowledgements) +- [Copyright and License](#copyright-and-license) + +--- + +### ABOUT _ENTWINE TEST_ + +_EntwineTest_ packages a concise set of tools that are designed to work together to help you test your _Combine_ sequences and operators. + +In addition, _EntwineTest_ includes tools to help you to determine whether your publishers are complying with subscriber demand requests (backpressure) so that you can ensure your publisher is behaving like a good _Combine_ citizen before releasing it out in the wild. + +--- + +### GETTING STARTED + +The _EntwineTest_ packages consists of three major components – together, they help you write better tests for your _Combine_ sequences. + +Let's go through them one by one before finally seeing how they all fit together: + +## `TestScheduler`: + +At the heart of _Combine_ is the concept of schedulers. Without them, no work gets done. Essentially they are responsible for both _where_ an action is excuted (main thread, a dipatch queue, or the current thread), and _when_ it is is executed (right now, after the current task has finished, five minutes from now). + +Our `TestScheduler` is a special kind of scheduler that uses 'virtual time' to schedule its tasks on the current thread. Our `VirtualTime` is really just an `Int` and its only purpose is to prioritise the order in which tasks are done. However, for testing purposes, we pretend it is _actual time_, as it helps us to articulate the seqeunce in which we'd like our tests to run. + +The best thing about virtual time? It's instantaneous! So we keep our test suites lean and fast. + +Here's how you might use the `TestScheduler` in isolation: + +```swift +import EntwineTest + +let scheduler = TestScheduler() + +scheduler.schedule(after: 300) { print("bosh") } +scheduler.schedule(after: 200) { print("bash") } +scheduler.schedule(after: 100) { print("bish") } + +scheduler.resume() // the clock is paused until this is called + +// outputs: +// "bish" +// "bash" +// "bosh" + +``` +Notice that as we've scheduled "bosh" to print at `300`, and "bish" to print at `100`, when we start the scheduler by calling `.resume()`, "bish" is printed first. +## `TestablePublisher`: + +Now that we have our scheduler, we can think about how we're going to simulate some _Combine_ sequences. If we want to simulate a sequence, we'll need a publisher that lets us define _what_ each element a sequence should be, and _when_ that element should be emitted. + +A `TestablePublisher` is exactly that. + +You can generate a `TestablePublisher` from two factory methods on the `TestScheduler`. (We do it this way instead of instantiating directly as they depend on the scheduler.) + +One, `createAbsoluteTestablePublisher(_:)`, schedules events at exactly the time specified – if the time of an event has passed at the point the publisher is subscribed to, that event won't be fired. + +The other, `createRelativeTestablePublisher(_:)`, schedules events at the time specified _plus_ the time the publisher was subscribed to. So an event scheduled at `100` with a subscription at `200` means the event will fire at `300`. + +```swift +import Combine +import EntwineTest + +// we'll set the schedulers clock a little forward – at 200 + +let scheduler1 = TestScheduler(initialClock: 200) + +let relativeTimePublisher: TestablePublisher = scheduler.createRelativeTestablePublisher([ + (020, .input("Mi")), + (030, .input("Fa")), +]) + +let absoluteTimePublisher: TestablePublisher = scheduler.createAbsoluteTestablePublisher([ + (200, .input("Do")), + (210, .input("Re")), +]) + +let relativeSubscription = relativeTimePublisher.sink { element in + print("time: \(scheduler.now) - \(element)") +} + +let absoluteSubscription = absoluteTimePublisher.sink { element in + print("time: \(scheduler.now) - \(element)") +} + +scheduler.resume() + +// Outputs: +// time: 200 - Do +// time: 210 - Re +// time: 220 - Mi +// time: 230 - Fa +``` +Notice how the events events scheduled by the relative publisher fired _after_ the events scheduled by the absolute publisher. As we had set the time of our scheduler to `200` in its initializer, when we subscribed to our relative publisher with the `sink(_:)` method, our publisher took the current time and added that value to each scheduled event. + +## `TestableSubscriber`: + +The final piece in the jigsaw is the `TestableSubscriber`. Its role is to gather the output of a publisher so that it can be compared against some expected output. It also depends on the `TestScheduler`, so to get one we call `createTestableSubscriber(_:_:)` on our scheduler. + +Once we subscribe to a publisher, `TestableSubscriber` records all the events with their time of arrival and makes them available on its `.sequence` property ready for us to compare against some expected output. It also records the time the subscription began, as well as its completion (should it end). + +```swift +import Combine +import EntwineTest + +let scheduler = TestScheduler() +let passthroughSubject = PassthroughSubject() + +scheduler.schedule(after: 100) { passthroughSubject.send("yippee") } +scheduler.schedule(after: 200) { passthroughSubject.send("ki") } +scheduler.schedule(after: 300) { passthroughSubject.send("yay") } + +let subscriber = scheduler.createTestableSubscriber(String.self, Never.self) + +passthroughSubject.subscribe(subscriber) + +scheduler.resume() + +let expected = TestSequence = [ + (000, .subscription), + (100, .input("yippee")), + (200, .input("ki")), + (300, .input("yay")), +] + +print("sequences match: \(expected = subscriber.sequence)") + +// outputs: +// sequences match: true +``` + +## Putting it all together +Now that we have our `TestScheduler`, `TestPublisher`, and `TestSubscriber` let's put them together to test our operators and sequences. + +But first, there's one additional method that you should be aware of. That's the `start(_:)` method on `TestScheduler`. + +The `start(_:)` method accepts a closure that produces any publisher and then: +1. Schedules the creation of the publisher (invocation of the passed closure) at `100` +2. Schedules the subscription of the publisher to a `TestableSubscriber` at `200` +3. Schedules the cancellation of the subscription at `900` +4. Resumes the scheduler clock + +_These are all configurable by using the `start(configuration:_:)` method. See the docs for more info._ + +With that knowledge in place, let's test _Combine_'s map operator. (I'm sure it's fine – but just in case.) + +```swift + +import XCTest +import EntwineTest + +func testMap() { + + let testScheduler = TestScheduler(initialClock: 0) + + // creates a publisher that will schedule it's elements relatively, at the point of subscription + let testablePublisher: TestablePublisher = testScheduler.createRelativeTestablePublisher([ + (100, .input("a")), + (200, .input("b")), + (300, .input("c")), + ]) + + // a publisher that maps strings to uppercase + let subjectUnderTest = testablePublisher.map { $0.uppercased() } + + // uses the method described above (schedules a subscription at 200, to be cancelled at 900) + let results = testScheduler.start { subjectUnderTest } + + XCTAssertEqual(results.sequence, [ + (200, .subscription), // subscribed at 200 + (300, .input("A")), // received uppercased input @ 100 + subscription time + (400, .input("B")), // received uppercased input @ 200 + subscription time + (500, .input("C")), // received uppercased input @ 300 + subscription time + (900, .completion(.finished)), // subscription cancelled + ]) +} +``` +Hopefully this should be everything you need to get you started with testing your _Combine_ sequences. Don't forget that further information can be found [in the docs](http://tcldr.github.io/Entwine/EntwineTestDocs). + +--- + +### INSTALLATION +### As part of another Swift Package: +1. Include it in your `Package.swift` file as both a dependency and a dependency of your test target. + +```swift +import PackageDescription + +let package = Package( + ... + dependencies: [ + .package(url: "http://github.com/tcldr/EntwineTest.git", .upToNextMajor(from: "0.1.0")), + ], + ... + targets: [.testTarget(name: "MyTestTarget", dependencies: ["EntwineTest"]), + ] +) +``` + +2. Then run `swift package update` from the root directory of your SPM project. If you're using Xcode 11 to edit your SPM project this should happen automatically. + +### As part of an Xcode 11 or greater project: +1. Select the `File -> Swift Packages -> Add package dependency...` menu item. +2. Enter the repository url `https://github.com/tcldr/Entwine` and tap next. +3. Select 'version, 'up to next major', enter `0.1.0`, hit next. +4. Select the _EntwineTest_ library and specify the test target you wish to use it with. + + +--- + +### DOCUMENTATION +Full documentation for _EntwineTest_ can be found at [http://tcldr.github.io/Entwine/EntwineTestDocs](http://tcldr.github.io/Entwine/EntwineTestDocs). + +--- + +### ACKNOWLEDGEMENTS +_EntwineTest_ is inspired by the great work done in the _RxTest_ library by the contributors to [_RxSwift_](https://github.com/ReactiveX/RxSwift). + +--- + +### COPYRIGHT AND LICENSE +Copyright 2019 © Tristan Celder + +_EntwineTest_ is made available under the [MIT License](http://github.com/tcldr/Entwine/blob/master/LICENSE) + +--- \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc88f3b --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +MIT License + +Entwine +https://github.com/tcldr/Entwine + +Copyright © 2019 Tristan Celder. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Package.swift b/Package.swift index fe41cac..f8fe8d9 100644 --- a/Package.swift +++ b/Package.swift @@ -4,25 +4,30 @@ import PackageDescription let package = Package( - name: "TestScheduler", + name: "Entwine", + platforms: [ + .macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6) + ], products: [ - // Products define the executables and libraries produced by a package, and make them visible to other packages. .library( - name: "TestScheduler", - targets: ["TestScheduler"]), - ], - dependencies: [ - // Dependencies declare other packages that this package depends on. - // .package(url: /* package url */, from: "1.0.0"), + name: "Entwine", + targets: ["Entwine"]), + .library( + name: "EntwineTest", + targets: ["EntwineTest"]), ], targets: [ - // Targets are the basic building blocks of a package. A target can define a module or a test suite. - // Targets can depend on other targets in this package, and on products in packages which this package depends on. .target( - name: "TestScheduler", + name: "Entwine", dependencies: []), + .target( + name: "EntwineTest", + dependencies: ["Entwine"]), + .testTarget( + name: "EntwineTests", + dependencies: ["Entwine", "EntwineTest"]), .testTarget( - name: "TestSchedulerTests", - dependencies: ["TestScheduler"]), + name: "EntwineTestTests", + dependencies: ["EntwineTest"]), ] ) diff --git a/README.md b/README.md index a771550..3343ae5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,116 @@ -# TestScheduler -A description of this package. + +# [Entwine](https://github.com/tcldr/Entwine) + +Accessories for [Apple's Combine Framework](https://developer.apple.com/documentation/combine). + +--- + +### ABOUT +Entwine consists of three libraries (over two repositories) to be used in conjuction with Apple's Combine framework: +- The [_Entwine Utilities library_](https://github.com/tcldr/Entwine/blob/master/Assets/Entwine/README.md) includes additional operators, subjects and utilities for working with Combine sequences. +The package currently includes a `ReplaySubject`, a `withLatest(from:)` operator and a `Publishers.Factory` for rapidly defining publishers inline from any source. +- The [_EntwineTest library_](https://github.com/tcldr/Entwine/blob/master/Assets/EntwineTest/README.md) consists of tools for verifying expected behavior of Combine sequences. It houses +a `TestScheduler` that uses virtual time, a `TestablePublisher` that schedules a user-defined sequence of +elements in absolute or relative time, and a `TestableSubscriber` that record a time-stamped list of events that can +be compared against expected behavior. +- The [_EntwineRx library_](https://github.com/tcldr/EntwineRx/blob/master/README.md) is a small library maintained under a [separate repository](https://github.com/tcldr/EntwineRx) that contains bridging operators from RxSwift to Combine and vice versa +making _RxSwift_ and _Combine_ work together seamlessly. + +_Note: EntwineRx is maintained as a separate Swift package to minimize the SPM dependency graph_. + + +--- + +### QUICK START GUIDE +## Make publishers from any source +Use the [`Publishers.Factory`](https://tcldr.github.io/Entwine/EntwineDocs/Extensions/Publishers/Factory.html) publisher from the _Entwine_ package to effortlessly create a publisher that +meets Combine's backpressure requirements from any source. [Find out more about the _Entwine Utilities_ library.](https://github.com/tcldr/Entwine/blob/master/Assets/Entwine/README.md) + +_Inline publisher creation for PhotoKit authorization status:_ +```swift + +import Entwine + +let photoKitAuthorizationStatus = Publishers.Factory { dispatcher in + let status = PHPhotoLibrary.authorizationStatus() + switch status { + case .notDetermined: + PHPhotoLibrary.requestAuthorization { newStatus in + dispatcher.forward(newStatus) + dispatcher.forwardCompletion(.finished) + } + case .restricted, .denied, .authorized: + dispatcher.forward(.authorized) + dispatcher.forwardCompletion(.finished) + } +} +``` +## Test publisher behavior +Use the `TestScheduler`, `TestablePublisher` and `TestableSubscriber` to simulate _Combine_ sequences and test against expected output. [Find out more about the _EntwineTest_ library](https://github.com/tcldr/Entwine/blob/master/Assets/Entwine/README.md) + +_Testing Combine's `map(_:)` operator:_ + +```swift + +import XCTest +import EntwineTest + +func testMap() { + + let testScheduler = TestScheduler(initialClock: 0) + + // creates a publisher that will schedule it's elements relatively, at the point of subscription + let testablePublisher: TestablePublisher = testScheduler.createRelativeTestablePublisher([ + (100, .input("a")), + (200, .input("b")), + (300, .input("c")), + ]) + + let subjectUnderTest = testablePublisher.map { $0.uppercased() } + + // schedules a subscription at 200, to be cancelled at 900 + let results = testScheduler.start { subjectUnderTest } + + XCTAssertEqual(results.sequence, [ + (200, .subscription), // subscribed at 200 + (300, .input("A")), // received uppercased input @ 100 + subscription time + (400, .input("B")), // received uppercased input @ 200 + subscription time + (500, .input("C")), // received uppercased input @ 300 + subscription time + (900, .completion(.finished)), // subscription cancelled + ]) +} +``` + +## Use your _RxSwift_ view models with _SwiftUI_ +First, make sure you add the [_EntwineRx Swift Package_](https://github.com/tcldr/EntwineRx) (located in an external repo) as a dependency to your project. + +_Example coming soon_ + +--- + +### REQUIREMENTS +Entwine sits on top of Apple's Combine framework and therefore requires _Xcode 11_ and is has minimum deployment targets of _macOS 10.15_, _iOS 13_, _tvOS 13_ or _watchOS 6_. + +--- + +### INSTALLATION +Entwine is delivered via a Swift Package and can be installed either as a dependency to another Swift Package by adding it to the dependencies section of a `Package.swift` file +or to an Xcode 11 project by via the `File -> Swift Packages -> Add package dependency...` menu in Xcode 11. + +--- + +### DOCUMENTATION +Documentation for each package is available at: +- [Entwine Documentation](https://tcldr.github.io/Entwine/EntwineDocs/) (Operators, Publishers and Accessories) +- [EntwineTest Documentation](https://tcldr.github.io/Entwine/EntwineTestDocs/) (Tools for testing _Combine_ sequence behavior) +- [EntwineRx Documentation](https://tcldr.github.io/Entwine/EntwineRxDocs/) (Bridging operators for _RxSwift_) + +--- + +### COPYRIGHT AND LICENSE + +This project is released under the [MIT license](https://github.com/tcldr/Entwine/blob/master/LICENSE) + +--- + diff --git a/Sources/Common/DataStructures/LinkedListQueue.swift b/Sources/Common/DataStructures/LinkedListQueue.swift new file mode 100644 index 0000000..8df79fd --- /dev/null +++ b/Sources/Common/DataStructures/LinkedListQueue.swift @@ -0,0 +1,109 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// MARK: - LinkedListQueue definition + +/// FIFO Queue based on a dual linked-list data structure +struct LinkedListQueue { + + static var empty: LinkedListQueue { LinkedListQueue() } + + typealias Subnode = LinkedList + + private var regular = Subnode.empty + private var inverse = Subnode.empty + private (set) var count = 0 + + /// O(n) where n is the length of the sequence + init(_ elements: S) where S.Element == Element { + self.inverse = LinkedList(elements.reversed()) + self.regular = .empty + } + + /// This is an O(1) operation + var isEmpty: Bool { + switch (regular, inverse) { + case (.empty, .empty): + return true + default: + return false + } + } + + /// This is an O(1) operation + mutating func enqueue(_ element: Element) { + inverse = .value(element, tail: inverse) + count += 1 + } + + /// This is an O(1) operation + mutating func dequeue() -> Element? { + // Assuming the entire queue is consumed this is actually an O(1) operation. + // This is because each element only passes through the expensive O(n) reverse + // operation a single time and remains there until ready to be dequeued. + switch (regular, inverse) { + case (.empty, .empty): + return nil + case (.value(let head, let tail), _): + regular = tail + count -= 1 + return head + default: + regular = inverse.reversed + inverse = .empty + return dequeue() + } + } +} + +// MARK: - Sequence conformance + +extension LinkedListQueue: Sequence { + + typealias Iterator = Self + + __consuming func makeIterator() -> LinkedListQueue { + return self + } +} + +// MARK: - IteratorProtocol conformance + +extension LinkedListQueue: IteratorProtocol { + + mutating func next() -> Element? { + return dequeue() + } +} + +// MARK: - ExpressibleByArrayLiteral conformance + +extension LinkedListQueue: ExpressibleByArrayLiteral { + + typealias ArrayLiteralElement = Element + + init(arrayLiteral elements: Element...) { + self.init(elements) + } +} diff --git a/Sources/Common/DataStructures/LinkedListStack.swift b/Sources/Common/DataStructures/LinkedListStack.swift new file mode 100644 index 0000000..e4abfaf --- /dev/null +++ b/Sources/Common/DataStructures/LinkedListStack.swift @@ -0,0 +1,132 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// MARK: - LinkedList definition + +/// Value based linked list data structure. Effective as a LIFO Queue. +struct LinkedListStack { + + typealias Node = LinkedList + + private (set) var node = Node.empty + private (set) var count: Int = 0 + + init(_ elements: C) where C.Element == Element { + node = LinkedList(elements) + count = elements.count + } + + func peek() -> Element? { + node.value + } + + mutating func push(_ element: Element) { + node.prepend(element) + count += 1 + } +} + +// MARK: - IteratorProtocol conformance + +extension LinkedListStack: IteratorProtocol { + + mutating func next() -> Element? { + guard let value = node.poll() else { return nil } + count -= 1 + return value + } +} + +// MARK: - Sequence conformance + +extension LinkedListStack: Sequence { + + typealias Iterator = Self + + __consuming func makeIterator() -> Self { + return self + } +} + +// MARK: - ExpressibleByArrayLiteral conformance + +extension LinkedListStack: ExpressibleByArrayLiteral { + + typealias ArrayLiteralElement = Element + + init(arrayLiteral elements: Element...) { + self.init(elements) + } +} + +// MARK: - LinkedListNode definition + +indirect enum LinkedList { + case value(Element, tail: LinkedList) + case empty +} + +extension LinkedList { + + typealias Index = Int + + init(_ elements: S) where S.Element == Element { + self = elements.reduce(Self.empty) { acc, element in .value(element, tail: acc) } + } + + var isEmpty: Bool { + guard case .empty = self else { return false } + return true + } + + var reversed: Self { + Self.reverse(self) + } + + var value: Element? { + guard case .value(let head, _) = self else { return nil } + return head + } + + var tail: LinkedList? { + guard case .value(_, let tail) = self else { return nil } + return tail + } + + mutating func prepend(_ element: Element) { + self = .value(element, tail: self) + } + + mutating func poll() -> Element? { + guard case .value(let head, let tail) = self else { return nil } + self = tail + return head + } + + private static func reverse(_ node: Self, accumulator: Self = .empty) -> Self { + guard case .value(let head, let tail) = node else { return accumulator } + return reverse(tail, accumulator: .value(head, tail: accumulator)) + } +} + diff --git a/Sources/Common/DataStructures/PriorityQueue.swift b/Sources/Common/DataStructures/PriorityQueue.swift new file mode 100644 index 0000000..5783013 --- /dev/null +++ b/Sources/Common/DataStructures/PriorityQueue.swift @@ -0,0 +1,190 @@ +// +// SwiftPriorityQueue.swift +// SwiftPriorityQueue +// +// Copyright (c) 2015-2019 David Kopec +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// This code was inspired by Section 2.4 of Algorithms by Sedgewick & Wayne, 4th Edition +// +// Lifted from: https://github.com/davecom/SwiftPriorityQueue/blob/master/Sources/SwiftPriorityQueue/SwiftPriorityQueue.swift. + + +/// A PriorityQueue takes objects to be pushed of any type that implements Comparable. +/// It will pop the objects in the order that they would be sorted. A pop() or a push() +/// can be accomplished in O(lg n) time. It can be specified whether the objects should +/// be popped in ascending or descending order (Max Priority Queue or Min Priority Queue) +/// at the time of initialization. +/// +struct PriorityQueue { + + fileprivate var heap = [T]() + private let ordered: (T, T) -> Bool + + init(ascending: Bool = false, startingValues: [T] = []) { + self.init(order: ascending ? { $0 > $1 } : { $0 < $1 }, startingValues: startingValues) + } + + /// Creates a new PriorityQueue with the given ordering. + /// + /// - parameter order: A function that specifies whether its first argument should + /// come after the second argument in the PriorityQueue. + /// - parameter startingValues: An array of elements to initialize the PriorityQueue with. + init(order: @escaping (T, T) -> Bool, startingValues: [T] = []) { + ordered = order + + // Based on "Heap construction" from Sedgewick p 323 + heap = startingValues + var i = heap.count/2 - 1 + while i >= 0 { + sink(i) + i -= 1 + } + } + + /// How many elements the Priority Queue stores + var count: Int { return heap.count } + + /// true if and only if the Priority Queue is empty + var isEmpty: Bool { return heap.isEmpty } + + /// Add a new element onto the Priority Queue. O(lg n) + /// + /// - parameter element: The element to be inserted into the Priority Queue. + mutating func push(_ element: T) { + heap.append(element) + swim(heap.count - 1) + } + + /// Remove and return the element with the highest priority (or lowest if ascending). O(lg n) + /// + /// - returns: The element with the highest priority in the Priority Queue, or nil if the PriorityQueue is empty. + mutating func pop() -> T? { + + if heap.isEmpty { return nil } + if heap.count == 1 { return heap.removeFirst() } // added for Swift 2 compatibility + // so as not to call swap() with two instances of the same location + heap.swapAt(0, heap.count - 1) + let temp = heap.removeLast() + sink(0) + + return temp + } + + + /// Removes the first occurence of a particular item. Finds it by value comparison using ==. O(n) + /// Silently exits if no occurrence found. + /// + /// - parameter item: The item to remove the first occurrence of. + mutating func remove(_ item: T) { + if let index = heap.firstIndex(of: item) { + heap.swapAt(index, heap.count - 1) + heap.removeLast() + if index < heap.count { // if we removed the last item, nothing to swim + swim(index) + sink(index) + } + } + } + + /// Removes all occurences of a particular item. Finds it by value comparison using ==. O(n) + /// Silently exits if no occurrence found. + /// + /// - parameter item: The item to remove. + mutating func removeAll(_ item: T) { + var lastCount = heap.count + remove(item) + while (heap.count < lastCount) { + lastCount = heap.count + remove(item) + } + } + + /// Get a look at the current highest priority item, without removing it. O(1) + /// + /// - returns: The element with the highest priority in the PriorityQueue, or nil if the PriorityQueue is empty. + func peek() -> T? { + return heap.first + } + + /// Eliminate all of the elements from the Priority Queue. + mutating func clear() { + heap.removeAll(keepingCapacity: false) + } + + // Based on example from Sedgewick p 316 + mutating func sink(_ index: Int) { + var index = index + while 2 * index + 1 < heap.count { + + var j = 2 * index + 1 + + if j < (heap.count - 1) && ordered(heap[j], heap[j + 1]) { j += 1 } + if !ordered(heap[index], heap[j]) { break } + + heap.swapAt(index, j) + index = j + } + } + + // Based on example from Sedgewick p 316 + mutating func swim(_ index: Int) { + var index = index + while index > 0 && ordered(heap[(index - 1) / 2], heap[index]) { + heap.swapAt((index - 1) / 2, index) + index = (index - 1) / 2 + } + } +} + +// MARK: - GeneratorType +extension PriorityQueue: IteratorProtocol { + + typealias Element = T + mutating func next() -> Element? { return pop() } +} + +// MARK: - SequenceType +extension PriorityQueue: Sequence { + + typealias Iterator = PriorityQueue + func makeIterator() -> Iterator { return self } +} + +// MARK: - CollectionType +extension PriorityQueue: Collection { + + typealias Index = Int + + var startIndex: Int { return heap.startIndex } + var endIndex: Int { return heap.endIndex } + + subscript(i: Int) -> T { return heap[i] } + + func index(after i: PriorityQueue.Index) -> PriorityQueue.Index { + return heap.index(after: i) + } +} + +// MARK: - CustomStringConvertible, CustomDebugStringConvertible +extension PriorityQueue: CustomStringConvertible, CustomDebugStringConvertible { + + var description: String { return heap.description } + var debugDescription: String { return heap.debugDescription } +} diff --git a/Sources/Common/Utilities/SinkQueue.swift b/Sources/Common/Utilities/SinkQueue.swift new file mode 100644 index 0000000..00b32fa --- /dev/null +++ b/Sources/Common/Utilities/SinkQueue.swift @@ -0,0 +1,86 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +// MARK: - SinkQueue definition + +class SinkQueue { + + private var sink: Sink? + private var buffer = LinkedListQueue() + + private var demandRequested = Subscribers.Demand.none + private var demandProcessed = Subscribers.Demand.none + private var demandForwarded = Subscribers.Demand.none + private var demandQueued: Subscribers.Demand { .max(buffer.count) } + + private var completion: Subscribers.Completion? + + init(sink: Sink) { + self.sink = sink + } + + deinit { + expediteCompletion(.finished) + } + + func requestDemand(_ demand: Subscribers.Demand) -> Subscribers.Demand { + demandRequested += demand + return processDemand() + } + + func enqueue(_ input: Sink.Input) -> Subscribers.Demand { + buffer.enqueue(input) + return processDemand() + } + + func enqueue(completion: Subscribers.Completion) -> Subscribers.Demand { + self.completion = completion + return processDemand() + } + + func expediteCompletion(_ completion: Subscribers.Completion) { + guard let sink = sink else { return } + self.sink = nil + self.buffer = .empty + sink.receive(completion: completion) + } + + // Processes as much demand as requested, returns spare capacity that + // can be forwarded to upstream subscriber/s + func processDemand() -> Subscribers.Demand { + while demandProcessed < demandRequested, let next = buffer.next() { + demandProcessed += 1 + demandRequested += sink?.receive(next) ?? .none + } + if let completion = completion, demandQueued < 1 { + expediteCompletion(completion) + return .none + } + let spareDemand = max(.none, demandRequested - demandProcessed - demandQueued - demandForwarded) + demandForwarded += spareDemand + return spareDemand + } +} diff --git a/Sources/Entwine/Common/DataStructures/LinkedListQueue.swift b/Sources/Entwine/Common/DataStructures/LinkedListQueue.swift new file mode 120000 index 0000000..f6b27b7 --- /dev/null +++ b/Sources/Entwine/Common/DataStructures/LinkedListQueue.swift @@ -0,0 +1 @@ +../../../Common/DataStructures/LinkedListQueue.swift \ No newline at end of file diff --git a/Sources/Entwine/Common/DataStructures/LinkedListStack.swift b/Sources/Entwine/Common/DataStructures/LinkedListStack.swift new file mode 120000 index 0000000..db3a3e6 --- /dev/null +++ b/Sources/Entwine/Common/DataStructures/LinkedListStack.swift @@ -0,0 +1 @@ +../../../Common/DataStructures/LinkedListStack.swift \ No newline at end of file diff --git a/Sources/Entwine/Common/DataStructures/PriorityQueue.swift b/Sources/Entwine/Common/DataStructures/PriorityQueue.swift new file mode 120000 index 0000000..6fce9e3 --- /dev/null +++ b/Sources/Entwine/Common/DataStructures/PriorityQueue.swift @@ -0,0 +1 @@ +../../../Common/DataStructures/PriorityQueue.swift \ No newline at end of file diff --git a/Sources/Entwine/Common/Utilities/SinkQueue.swift b/Sources/Entwine/Common/Utilities/SinkQueue.swift new file mode 120000 index 0000000..bfa45b5 --- /dev/null +++ b/Sources/Entwine/Common/Utilities/SinkQueue.swift @@ -0,0 +1 @@ +../../../Common/Utilities/SinkQueue.swift \ No newline at end of file diff --git a/Sources/Entwine/Operators/Dematerialize.swift b/Sources/Entwine/Operators/Dematerialize.swift new file mode 100644 index 0000000..b59f3ee --- /dev/null +++ b/Sources/Entwine/Operators/Dematerialize.swift @@ -0,0 +1,270 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +/// Represents an error for a dematerialized sequence +/// +/// Consumers of publishers with a `Failure` of this type can opt-in to force unwrapping +/// the error using the `assertNoDematerializationFailure()` operator +public enum DematerializationError: Error { + /// Sequencing error during dematerialization. e.g. an `.input` arriving after a `.completion` + case outOfSequence + /// A wrapped error of the represented material sequence + case sourceError(SourceError) +} + +extension DematerializationError: Equatable where SourceError: Equatable {} + +extension Publishers { + + /// Converts a materialized publisher of `Signal`s into the represented sequence. Fails on a malformed + /// source sequence. + /// + /// Use this operator to convert a stream of `Signal` values from an upstream publisher into + /// its materially represented publisher type. Malformed sequences will fail with a + /// `DematerializationError`. + /// + /// For each element: + /// - `.subscription` elements are ignored + /// - `.input(_)` elements are unwrapped and forwarded to the subscriber + /// - `.completion(_)` elements are forwarded within the `DematerializationError` wrapper + /// + /// If the integrity of the upstream sequence can be guaranteed, applying the `assertNoDematerializationFailure()` + /// operator to this publisher will force unwrap any errors and produce a publisher with a `Failure` + /// type that matches the materially represented original sequence. + public struct Dematerialize: Publisher where Upstream.Output: SignalConvertible { + + public typealias Failure = DematerializationError + public typealias Output = AnyPublisher + + private let upstream: Upstream + + init(upstream: Upstream) { + self.upstream = upstream + } + + public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + subscriber.receive(subscription: DematerializeSubscription(upstream: upstream, downstream: subscriber)) + } + } + + fileprivate class DematerializeSubscription: Subscription + where + Upstream.Output: SignalConvertible, + Downstream.Input == AnyPublisher>, + Downstream.Failure == DematerializationError + { + var sink: DematerializeSink? + + init(upstream: Upstream, downstream: Downstream) { + let sink = DematerializeSink(upstream: upstream, downstream: downstream) + self.sink = sink + } + + // Subscription Methods + + // Called by the downstream subscriber to signal + // additional elements can be sent + func request(_ demand: Subscribers.Demand) { + sink?.signalDemand(demand) + } + + // Called by the downstream subscriber to end the + // subscription and clean up any resources. + // Shouldn't be a blocking call, but it is legal + // for a few more elements to arrive after this is + // called. + func cancel() { + self.sink = nil + } + } + + fileprivate class DematerializeSink: Subscriber + where + Upstream.Output: SignalConvertible, + Downstream.Input == AnyPublisher>, + Downstream.Failure == DematerializationError + { + + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + var queue: SinkQueue + var upstreamSubscription: Subscription? + var currentMaterializationSubject: PassthroughSubject? + + init(upstream: Upstream, downstream: Downstream) { + self.queue = SinkQueue(sink: downstream) + upstream.subscribe(self) + } + + deinit { + queue.expediteCompletion(.finished) + cancelUpstreamSubscription() + } + + // Called by the upstream publisher (or its agent) to signal + // that the subscription has begun + func receive(subscription: Subscription) { + self.upstreamSubscription = subscription + let demand = queue.processDemand() + guard demand > .none else { return } + subscription.request(demand) + } + + // Called by the upstream publisher (or its agent) to signal + // an element has arrived, signals to the upstream publisher + // how many more elements can be sent + func receive(_ input: Input) -> Subscribers.Demand { + + switch input.signal { + case .subscription: + guard currentMaterializationSubject == nil else { + queue.expediteCompletion(.failure(.outOfSequence)) + cancelUpstreamSubscription() + return .none + } + currentMaterializationSubject = .init() + return queue.enqueue(currentMaterializationSubject!.eraseToAnyPublisher()) + + case .input(let dematerializedInput): + guard let subject = currentMaterializationSubject else { + queue.expediteCompletion(.failure(.outOfSequence)) + cancelUpstreamSubscription() + return .none + } + subject.send(dematerializedInput) + // re-imburse the sender as we're not queueing an + // additional element on the outer stream, only + // sending an element on the inner-stream + return .max(1) + + case .completion(let dematerializedCompletion): + guard let subject = currentMaterializationSubject else { + queue.expediteCompletion(.failure(.outOfSequence)) + cancelUpstreamSubscription() + return .none + } + currentMaterializationSubject = nil + subject.send(completion: wrapSourceCompletion(dematerializedCompletion)) + // re-imburse the sender as we're not queueing an + // additional element on the outer stream, only + // sending an element on the inner-stream + return .none + } + } + + func wrapSourceCompletion(_ completion: Subscribers.Completion) -> Subscribers.Completion> { + guard case .failure(let error) = completion else { return .finished } + return .failure(.sourceError(error)) + } + + // Called by the upstream publisher (or its agent) to signal + // that the sequence has terminated + func receive(completion: Subscribers.Completion) { + _ = queue.enqueue(completion: .finished) + cancelUpstreamSubscription() + } + + // Indirectly called by the downstream subscriber via its subscription + // to signal that more items can be sent downstream + func signalDemand(_ demand: Subscribers.Demand) { + let spareDemand = queue.requestDemand(demand) + guard spareDemand > .none else { return } + upstreamSubscription?.request(spareDemand) + } + + func cancelUpstreamSubscription() { + upstreamSubscription?.cancel() + upstreamSubscription = nil + } + } +} + +extension Publisher where Output: SignalConvertible, Failure == Never { + + private func dematerializedValuesPublisherSequence() -> Publishers.Dematerialize { + Publishers.Dematerialize(upstream: self) + } + + /// Converts a materialized upstream publisher of `Signal`s into the represented sequence. Fails on + /// a malformed source sequence. + /// + /// Use this operator to convert a stream of `Signal` values from an upstream publisher into + /// its materially represented publisher type. Malformed sequences will fail with a + /// `DematerializationError`. + /// + /// For each element: + /// - `.subscription` elements are ignored + /// - `.input(_)` elements are unwrapped and forwarded to the subscriber + /// - `.completion(_)` elements are forwarded within the `DematerializationError` wrapper + /// + /// If the integrity of the upstream sequence can be guaranteed, use the `assertNoDematerializationFailure()` + /// operator immediately following this one to force unwrap any errors and produce a publisher with a `Failure` + /// type that matches the materially represented original sequence. + /// + /// - Returns: A publisher that materializes an upstream publisher of `Signal`s into the represented + /// sequence. + public func dematerialize() -> Publishers.FlatMap>, Publishers.First>> { + return dematerializedValuesPublisherSequence().first().flatMap { $0 } + } +} + +extension Publisher where Failure: DematerializationErrorConvertible { + + /// Force unwraps the errors of a dematerialized publisher to return a publisher that matches that of + /// the materially represented original sequence + /// + /// When using the `dematerialize()` operator the publisher returned has a `Failure` type of + /// `DematerializationError` to account for the possibility of a malformed `Signal` sequence. + /// + /// If the integrity of the upstream sequence can be guaranteed, use this operator to force unwrap the + /// errors to produce a publisher with a `Failure` type that matches the materially represented original + /// sequence. + /// + /// - Returns: A publisher with a `Failure` type that matches that of the materially represented original + /// sequence + func assertNoDematerializationFailure() -> Publishers.MapError { + return mapError { error -> Failure.SourceError in + guard case .sourceError(let e) = error.dematerializationError else { + preconditionFailure("Unhandled dematerialization error: \(error)") + } + return e + } + } +} + +/// A type which can be converted into a `DematerializationError` +public protocol DematerializationErrorConvertible { + + associatedtype SourceError: Error + + /// The type represented as a `DematerializationError` + var dematerializationError: DematerializationError { get } +} + +extension DematerializationError: DematerializationErrorConvertible { + public var dematerializationError: DematerializationError { self } +} diff --git a/Sources/Entwine/Operators/Materialize.swift b/Sources/Entwine/Operators/Materialize.swift new file mode 100644 index 0000000..42c4006 --- /dev/null +++ b/Sources/Entwine/Operators/Materialize.swift @@ -0,0 +1,145 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +extension Publishers { + + /// Wraps all the elements as well as the subscription and completion events of an upstream publisher + /// into a stream of `Signal` elements + public struct Materialize: Publisher { + + public typealias Output = Signal + public typealias Failure = Never + + private let upstream: Upstream + + init(upstream: Upstream) { + self.upstream = upstream + } + + public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + subscriber.receive(subscription: MaterializeSubscription(upstream: upstream, downstream: subscriber)) + } + } + + // Owned by the downstream subscriber + fileprivate class MaterializeSubscription: Subscription + where Never == Downstream.Failure, Signal == Downstream.Input + { + var sink: MaterializeSink? + + init(upstream: Upstream, downstream: Downstream) { + let sink = MaterializeSink(upstream: upstream, downstream: downstream) + self.sink = sink + } + + // Subscription Methods + + // Called by the downstream subscriber to signal + // additional elements can be sent + func request(_ demand: Subscribers.Demand) { + sink?.signalDemand(demand) + } + + // Called by the downstream subscriber to end the + // subscription and clean up any resources. + // Shouldn't be a blocking call, but it is legal + // for a few more elements to arrive after this is + // called. + func cancel() { + self.sink = nil + } + } + + fileprivate class MaterializeSink: Subscriber + where Never == Downstream.Failure, Signal == Downstream.Input + { + + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + var queue: SinkQueue + var upstreamSubscription: Subscription? + + init(upstream: Upstream, downstream: Downstream) { + self.queue = SinkQueue(sink: downstream) + upstream.subscribe(self) + } + + deinit { + queue.expediteCompletion(.finished) + cancelUpstreamSubscription() + } + + // Called by the upstream publisher (or its agent) to signal + // that the subscription has begun + func receive(subscription: Subscription) { + self.upstreamSubscription = subscription + let demand = queue.enqueue(.subscription) + guard demand > .none else { return } + subscription.request(demand) + } + + // Called by the upstream publisher (or its agent) to signal + // an element has arrived, signals to the upstream publisher + // how many more elements can be sent + func receive(_ input: Input) -> Subscribers.Demand { + return queue.enqueue(.input(input)) + } + + // Called by the upstream publisher (or its agent) to signal + // that the sequence has terminated + func receive(completion: Subscribers.Completion) { + _ = queue.enqueue(.completion(completion)) + _ = queue.enqueue(completion: .finished) + cancelUpstreamSubscription() + } + + // Indirectly called by the downstream subscriber via its subscription + // to signal that more items can be sent downstream + func signalDemand(_ demand: Subscribers.Demand) { + let spareDemand = queue.requestDemand(demand) + guard spareDemand > .none else { return } + upstreamSubscription?.request(spareDemand) + } + + func cancelUpstreamSubscription() { + upstreamSubscription?.cancel() + upstreamSubscription = nil + } + } +} + +public extension Publisher { + + /// Wraps each element from the upstream publisher, as well as its subscription and completion events, + /// into `Signal` values. + /// + /// - Returns: A publisher that wraps each element from the upstream publisher, as well as its + /// subscription and completion events, into `Signal` values. + func materialize() -> Publishers.Materialize { + Publishers.Materialize(upstream: self) + } +} diff --git a/Sources/Entwine/Operators/ReplaySubject.swift b/Sources/Entwine/Operators/ReplaySubject.swift new file mode 100644 index 0000000..7dc0b86 --- /dev/null +++ b/Sources/Entwine/Operators/ReplaySubject.swift @@ -0,0 +1,173 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +/// A subject that maintains a buffer of its latest values for replay to new subscribers and passes +/// through subsequent elements and completion +/// +/// The subject passes through elements and completion states unchanged and in addition +/// replays the latest elements to any new subscribers. Use this subject when you want subscribers +/// to receive the most recent previous elements in addition to all future elements. +public final class ReplaySubject { + + typealias Sink = AnySubscriber + + private enum Status { case active, completed } + + private var status = Status.active + private var subscriptions = [ReplaySubjectSubscription]() + private var subscriberIdentifiers = Set() + + private var buffer = [Output]() + private var replayValues: ReplaySubjectValueBuffer + + var subscriptionCount: Int { + return subscriptions.count + } + + /// - Parameter maxBufferSize: The number of elements that should be buffered for + /// replay to new subscribers + /// - Returns: A subject that maintains a buffer of its recent values for replay to new subscribers + /// and passes through subsequent values and completion + public init(maxBufferSize: Int) { + self.replayValues = .init(maxBufferSize: maxBufferSize) + } +} + +extension ReplaySubject: Publisher { + + public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + + guard status != .completed, !subscriberIdentifiers.contains(subscriber.combineIdentifier) else { + subscriber.receive(subscription: Subscriptions.empty) + subscriber.receive(completion: .finished) + return + } + + let subscriberIdentifier = subscriber.combineIdentifier + let subscription = ReplaySubjectSubscription(sink: AnySubscriber(subscriber), replayedInputs: replayValues.buffer) + + // we use seperate collections for identifiers and subscriptions + // to improve performance of identifier lookups and to keep the + // order in which subscribers are signalled to be in the order that + // they intially subscribed. + + subscriberIdentifiers.insert(subscriberIdentifier) + subscriptions.append(subscription) + + subscription.cleanupHandler = { [weak self] in + if let index = self?.subscriptions.firstIndex(where: { subscriberIdentifier == $0.subscriberIdentifier }) { + self?.subscriberIdentifiers.remove(subscriberIdentifier) + self?.subscriptions.remove(at: index) + } + } + subscriber.receive(subscription: subscription) + } +} + +extension ReplaySubject: Subject { + + public func send(_ value: Output) { + guard status == .active else { return } + replayValues.addValueToBuffer(value) + subscriptions.forEach { subscription in + subscription.forwardValueToSink(value) + } + } + + public func send(completion: Subscribers.Completion) { + guard status == .active else { return } + self.status = .completed + subscriptions.forEach { subscription in + subscription.forwardCompletionToSink(completion) + } + subscriptions.removeAll() + } +} + +fileprivate final class ReplaySubjectSubscription: Subscription { + + private let queue: SinkQueue + + var cleanupHandler: (() -> Void)? + let subscriberIdentifier: CombineIdentifier + + init(sink: Sink, replayedInputs: ReplayedInputs) where ReplayedInputs.Element == Sink.Input { + self.queue = SinkQueue(sink: sink) + self.subscriberIdentifier = sink.combineIdentifier + replayedInputs.forEach { _ = queue.enqueue($0) } + } + + func forwardValueToSink(_ value: Sink.Input) { + _ = queue.enqueue(value) + } + + func forwardCompletionToSink(_ completion: Subscribers.Completion) { + queue.expediteCompletion(completion) + cleanup() + } + + func request(_ demand: Subscribers.Demand) { + _ = queue.requestDemand(demand) + } + + func cancel() { + queue.expediteCompletion(.finished) + cleanup() + } + + func cleanup() { + cleanupHandler?() + cleanupHandler = nil + } +} + +extension ReplaySubject { + + public static func createUnbounded() -> ReplaySubject { + return .init(maxBufferSize: .max) + } + + public static func create(bufferSize: Int) -> ReplaySubject { + return .init(maxBufferSize: bufferSize) + } +} + +fileprivate struct ReplaySubjectValueBuffer { + + let maxBufferSize: Int + private (set) var buffer = LinkedListQueue() + + init(maxBufferSize: Int) { + self.maxBufferSize = maxBufferSize + } + + mutating func addValueToBuffer(_ value: Value) { + buffer.enqueue(value) + if buffer.count > maxBufferSize { + _ = buffer.dequeue() + } + } +} diff --git a/Sources/Entwine/Operators/ShareReplay.swift b/Sources/Entwine/Operators/ShareReplay.swift new file mode 100644 index 0000000..133b250 --- /dev/null +++ b/Sources/Entwine/Operators/ShareReplay.swift @@ -0,0 +1,42 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +extension Publisher { + /// Returns a publisher as a class instance that replays previous values to new subscribers + /// + /// The downstream subscriber receives elements and completion states unchanged from the + /// previous subscriber, and in addition replays the latest elements received from the upstream + /// subscriber to any new subscribers. Use this operator when you want new subscribers to + /// receive the most recently produced values immediately upon subscription. + /// + /// - Parameter maxBufferSize: The number of elements that should be buffered for + /// replay to new subscribers + /// - Returns: A class instance that republishes its upstream publisher and maintains a + /// buffer of its latest values for replay to new subscribers + public func share(replay maxBufferSize: Int) -> Publishers.Autoconnect>> { + multicast { ReplaySubject(maxBufferSize: maxBufferSize) }.autoconnect() + } +} diff --git a/Sources/Entwine/Operators/WithLatestFrom.swift b/Sources/Entwine/Operators/WithLatestFrom.swift new file mode 100644 index 0000000..4f660df --- /dev/null +++ b/Sources/Entwine/Operators/WithLatestFrom.swift @@ -0,0 +1,156 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +extension Publishers { + + /// A publisher that combines the latest value from another publisher with each value from an upstream publisher + public struct WithLatestFrom: Publisher where Upstream.Failure == Other.Failure { + + public typealias Failure = Upstream.Failure + + private let upstream: Upstream + private let other: Other + private let transform: (Upstream.Output, Other.Output) -> Output + + public init(upstream: Upstream, other: Other, transform: @escaping (Upstream.Output, Other.Output) -> Output) { + self.upstream = upstream + self.other = other + self.transform = transform + } + + public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + upstream.subscribe( + WithLatestFromSink( + downstream: subscriber, + otherSink: WithLatestFromOtherSink(publisher: other), + transform: transform + ) + ) + } + } + + fileprivate class WithLatestFromSink: Subscriber + where Upstream.Failure == Other.Failure, Upstream.Failure == Downstream.Failure { + + typealias Input = Upstream.Output + typealias Failure = Upstream.Failure + + let downstream: Downstream + let otherSink: WithLatestFromOtherSink + let transform: (Upstream.Output, Other.Output) -> Downstream.Input + + init(downstream: Downstream, otherSink: WithLatestFromOtherSink, transform: @escaping (Input, Other.Output) -> Downstream.Input) { + self.downstream = downstream + self.otherSink = otherSink + self.transform = transform + } + + func receive(subscription: Subscription) { + downstream.receive(subscription: subscription) + otherSink.subscribe() + } + + func receive(_ input: Input) -> Subscribers.Demand { + guard let otherInput = otherSink.lastInput else { + // we ignore results from the `Upstream` publisher until + // we have received an item from the `Other` publisher. + // + // As we are ignoring this item, we need to signal to the + // upstream publisher that they may reclaim the budget for + // the dropped item by returning a Subscribers.Demand of 1. + return .max(1) + } + return downstream.receive(transform(input, otherInput)) + } + + func receive(completion: Subscribers.Completion) { + otherSink.terminateSubscription() + downstream.receive(completion: completion) + } + } + + fileprivate class WithLatestFromOtherSink: Subscriber { + + typealias Input = P.Output + typealias Failure = P.Failure + + private let publisher: AnyPublisher + private (set) var lastInput: Input? + private var subscription: Subscription? + + + init(publisher: P) { + self.publisher = publisher.eraseToAnyPublisher() + } + + func subscribe() { + publisher.subscribe(self) + } + + func receive(subscription: Subscription) { + self.subscription = subscription + subscription.request(.unlimited) + } + + func receive(_ input: Input) -> Subscribers.Demand { + lastInput = input + return .none + } + + func receive(completion: Subscribers.Completion) { + subscription = nil + } + + func terminateSubscription() { + subscription?.cancel() + } + } +} + +public extension Publisher { + + /// Subscribes to an additional publisher and invokes a closure upon receiving output from this + /// publisher. + /// + /// - Parameter other: Another publisher to combibe with this one + /// - Parameter transform: A closure that receives each value produced by this + /// publisher and the latest value from another publisher and returns a new value to publish + /// - Returns: A publisher that combines the latest value from another publisher with each + /// value from this publisher + func withLatest(from other: P, transform: @escaping (Output, P.Output) -> T) -> Publishers.WithLatestFrom where P.Failure == Failure { + Publishers.WithLatestFrom(upstream: self, other: other, transform: transform) + } + + /// Subscribes to an additional publisher and produces its latest value each time this publisher + /// produces a value. + /// + /// - Parameter other: Another publisher to gather latest values from + /// - Returns: A publisher that produces the latest value from another publisher each time + /// this publisher produces an element + func withLatest(from other: P) -> Publishers.WithLatestFrom where P.Failure == Failure { + withLatest(from: other, transform: { _, b in b }) + } +} diff --git a/Sources/Entwine/Publishers/Factory.swift b/Sources/Entwine/Publishers/Factory.swift new file mode 100644 index 0000000..1466e6f --- /dev/null +++ b/Sources/Entwine/Publishers/Factory.swift @@ -0,0 +1,155 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +extension Publishers { + + /// Creates a simple publisher inline from a provided closure + /// + /// This publisher can be used to turn any arbitrary source of values (such as a timer or a user authorization + /// request) into a new publisher sequence. + /// + /// From within the scope of the closure passed into the initializer, it is possible to call the methods of the + /// `Dispatcher` object – which is passed in as a parameter – to send values down stream. + /// + /// + /// ## Example + /// + /// ```swift + /// + /// import Entwine + /// + /// let photoKitAuthorizationStatus = Publishers.Factory { dispatcher in + /// let status = PHPhotoLibrary.authorizationStatus() + /// switch status { + /// case .notDetermined: + /// PHPhotoLibrary.requestAuthorization { newStatus in + /// dispatcher.forward(newStatus) + /// dispatcher.forwardCompletion(.finished) + /// } + /// case .restricted, .denied, .authorized: + /// dispatcher.forward(.authorized) + /// dispatcher.forwardCompletion(.finished) + /// } + /// } + /// ``` + /// + /// - Warning: Developers should be aware that a `Dispatcher` has an unbounded buffer that stores values + /// yet to be requested by the downstream subscriber. + /// + /// When creating a publisher from a source with an unbounded rate of production that cannot be influenced, + /// developers should consider following this operator with a `Publishers.Buffer` operator to prevent a + /// strain on resources + public struct Factory: Publisher { + + let subscription: (Dispatcher) -> AnyCancellable + + public init(_ subscription: @escaping (Dispatcher) -> AnyCancellable) { + self.subscription = subscription + } + + public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + subscriber.receive(subscription: FactorySubscription(sink: subscriber, subscription: subscription)) + } + } +} + +fileprivate class FactorySubscription: Subscription { + + var subscription: ((Dispatcher) -> AnyCancellable)? + var dispatcher: FactoryDispatcher? + var cancellable: AnyCancellable? + + init(sink: Sink, subscription: @escaping (Dispatcher) -> AnyCancellable) { + self.subscription = subscription + self.dispatcher = FactoryDispatcher(sink: sink) + } + + func request(_ demand: Subscribers.Demand) { + _ = dispatcher?.queue.requestDemand(demand) + startUpstreamSubscriptionIfNeeded() + } + + func startUpstreamSubscriptionIfNeeded() { + guard let subscription = subscription, let dispatcher = dispatcher else { return } + self.subscription = nil + cancellable = subscription(dispatcher) + } + + func cancel() { + cancellable = nil + dispatcher = nil + } +} + +// MARK: - Public facing Dispatcher defintion + +/// Manages a queue of publisher sequence elements to be delivered to a subscriber +public class Dispatcher { + + /// Queues an element to be delivered to the subscriber + /// - Parameter input: a value to be delivered to a downstream subscriber + public func forward(_ input: Input) { + fatalError("Abstract class. Override in subclass.") + } + + /// Completes the sequence once any queued elements are delivered to the subscriber + /// - Parameter completion: a completion value to be delivered to the subscriber once + /// the remaining items in the queue have been delivered + public func forward(completion: Subscribers.Completion) { + fatalError("Abstract class. Override in subclass.") + } + + /// Completes the sequence immediately regardless of any elements that are waiting to be delivered + /// - Parameter completion: a completion value to be delivered immediately to the subscriber + public func forwardImmediately(completion: Subscribers.Completion) { + fatalError("Abstract class. Override in subclass.") + } +} + +// MARK: - Internal Dispatcher defintion + +class FactoryDispatcher: Dispatcher + where Input == Sink.Input, Failure == Sink.Failure +{ + + let queue: SinkQueue + + init(sink: Sink) { + self.queue = SinkQueue(sink: sink) + } + + public override func forward(_ input: Input) { + _ = queue.enqueue(input) + } + + public override func forward(completion: Subscribers.Completion) { + _ = queue.enqueue(completion: completion) + } + + public override func forwardImmediately(completion: Subscribers.Completion) { + queue.expediteCompletion(completion) + } +} diff --git a/Sources/Entwine/Schedulers/TrampolineScheduler.swift b/Sources/Entwine/Schedulers/TrampolineScheduler.swift new file mode 100644 index 0000000..76cb177 --- /dev/null +++ b/Sources/Entwine/Schedulers/TrampolineScheduler.swift @@ -0,0 +1,104 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine +import Foundation + +// MARK: - Class definition + +/// A scheduler for performing trampolined actions. +/// +/// This scheduler will queue scheduled actions immediately on the current thread performing them in a first in, first out order. +/// +/// You can only use this scheduler for immediate actions. If you attempt to schedule +/// actions after a specific date, the scheduler produces a fatal error. +public final class TrampolineScheduler { + + public static let shared = TrampolineScheduler() + + private static let localThreadActionQueueKey = "com.github.tcldr.Entwine.TrampolineScheduler.localThreadActionQueueKey" + + private static var localThreadActionQueue: TrampolineSchedulerQueue { + guard let queue = Thread.current.threadDictionary.value(forKey: Self.localThreadActionQueueKey) as? TrampolineSchedulerQueue else { + let newQueue = TrampolineSchedulerQueue() + Thread.current.threadDictionary.setValue(newQueue, forKey: Self.localThreadActionQueueKey) + return newQueue + } + return queue + } +} + +// MARK: - Scheduler conformance + +extension TrampolineScheduler: Scheduler { + + public typealias SchedulerTimeType = ImmediateScheduler.SchedulerTimeType + public typealias SchedulerOptions = ImmediateScheduler.SchedulerOptions + + public var now: TrampolineScheduler.SchedulerTimeType { + ImmediateScheduler.shared.now + } + + public var minimumTolerance: TrampolineScheduler.SchedulerTimeType.Stride { + ImmediateScheduler.shared.minimumTolerance + } + + public func schedule(options: SchedulerOptions?, _ action: @escaping () -> Void) { + Self.localThreadActionQueue.push(action) + } + + public func schedule(after date: SchedulerTimeType, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) { + fatalError("You can only use this scheduler for immediate actions") + } + + public func schedule(after date: SchedulerTimeType, interval: SchedulerTimeType.Stride, tolerance: SchedulerTimeType.Stride, options: SchedulerOptions?, _ action: @escaping () -> Void) -> Cancellable { + fatalError("You can only use this scheduler for immediate actions") + } +} + + +// MARK: - Scheduler Queue + +fileprivate final class TrampolineSchedulerQueue { + + typealias Action = () -> Void + enum Status { case idle, active } + + private var queuedActions = LinkedListQueue() + private var status = Status.idle + + func push(_ action: @escaping Action) { + queuedActions.enqueue(action) + dispatchQueuedActions() + } + + func dispatchQueuedActions() { + guard status == .idle else { return } + status = .active + while let action = queuedActions.next() { + action() + } + status = .idle + } +} diff --git a/Sources/Entwine/Signal.swift b/Sources/Entwine/Signal.swift new file mode 100644 index 0000000..f5f5c3f --- /dev/null +++ b/Sources/Entwine/Signal.swift @@ -0,0 +1,98 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +// MARK: - Signal value definition + +/// A materialized representation of a `Publisher`s output. +/// +/// Upon a call to subscribe, a legal `Publisher` produces signals in strictly the following order: +/// - Exactly one 'subscription' signal. +/// - Followed by zero or more 'input' signals. +/// - Terminated finally by a single 'completion' signal. +public enum Signal { + /// Sent by a `Publisher` to a `Subscriber` in acknowledgment of the `Subscriber`'s + /// subscription request. + case subscription + /// The payload of a subscription. Zero to many `.input(_)` signals may be produced + /// during the lifetime of a `Subscriber`'s subscription to a `Publisher`. + case input(Input) + /// The final signal sent to a `Subscriber` during a subscription to a `Publisher`. + /// Indicates termination of the stream as well as the reason. + case completion(Subscribers.Completion) +} + +// MARK: - Equatable conformance + +extension Signal: Equatable where Input: Equatable, Failure: Equatable { + + public static func ==(lhs: Signal, rhs: Signal) -> Bool { + switch (lhs, rhs) { + case (.subscription, .subscription): + return true + case (.input(let lhsInput), .input(let rhsInput)): + return (lhsInput == rhsInput) + case (.completion(let lhsCompletion), .completion(let rhsCompletion)): + return completionsMatch(lhs: lhsCompletion, rhs: rhsCompletion) + default: + return false + } + } + + fileprivate static func completionsMatch(lhs: Subscribers.Completion, rhs: Subscribers.Completion) -> Bool { + switch (lhs, rhs) { + case (.finished, .finished): + return true + case (.failure(let lhsError), .failure(let rhsError)): + return (lhsError == rhsError) + default: + return false + } + } +} + +/// A type that can be converted into a `Signal` +public protocol SignalConvertible { + + /// The `Input` type of the produced `Signal` + associatedtype Input + /// The `Failure` type of the produced `Signal` + associatedtype Failure: Error + + init(_ signal: Signal) + /// The converted `Signal` + var signal: Signal { get } +} + +extension Signal: SignalConvertible { + + public init(_ signal: Signal) { + self = signal + } + + public var signal: Signal { + return self + } +} diff --git a/Sources/EntwineTest/Common/DataStructures/LinkedListQueue.swift b/Sources/EntwineTest/Common/DataStructures/LinkedListQueue.swift new file mode 120000 index 0000000..f6b27b7 --- /dev/null +++ b/Sources/EntwineTest/Common/DataStructures/LinkedListQueue.swift @@ -0,0 +1 @@ +../../../Common/DataStructures/LinkedListQueue.swift \ No newline at end of file diff --git a/Sources/EntwineTest/Common/DataStructures/LinkedListStack.swift b/Sources/EntwineTest/Common/DataStructures/LinkedListStack.swift new file mode 120000 index 0000000..db3a3e6 --- /dev/null +++ b/Sources/EntwineTest/Common/DataStructures/LinkedListStack.swift @@ -0,0 +1 @@ +../../../Common/DataStructures/LinkedListStack.swift \ No newline at end of file diff --git a/Sources/EntwineTest/Common/DataStructures/PriorityQueue.swift b/Sources/EntwineTest/Common/DataStructures/PriorityQueue.swift new file mode 120000 index 0000000..6fce9e3 --- /dev/null +++ b/Sources/EntwineTest/Common/DataStructures/PriorityQueue.swift @@ -0,0 +1 @@ +../../../Common/DataStructures/PriorityQueue.swift \ No newline at end of file diff --git a/Sources/EntwineTest/Common/Utilities/SinkQueue.swift b/Sources/EntwineTest/Common/Utilities/SinkQueue.swift new file mode 120000 index 0000000..bfa45b5 --- /dev/null +++ b/Sources/EntwineTest/Common/Utilities/SinkQueue.swift @@ -0,0 +1 @@ +../../../Common/Utilities/SinkQueue.swift \ No newline at end of file diff --git a/Sources/EntwineTest/Signal+CustomDebugStringConvertible.swift b/Sources/EntwineTest/Signal+CustomDebugStringConvertible.swift new file mode 100644 index 0000000..b7f3615 --- /dev/null +++ b/Sources/EntwineTest/Signal+CustomDebugStringConvertible.swift @@ -0,0 +1,40 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Entwine + +// MARK: - CustomDebugStringConvertible conformance + +extension Signal: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .subscription: + return ".subscribe" + case .input(let input): + return ".input(\(input))" + case .completion(let completion): + return ".completion(\(completion))" + } + } +} diff --git a/Sources/EntwineTest/TestEvent.swift b/Sources/EntwineTest/TestEvent.swift new file mode 100644 index 0000000..ef1dc9b --- /dev/null +++ b/Sources/EntwineTest/TestEvent.swift @@ -0,0 +1,49 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine +import Entwine + +struct TestEvent { + + let time: VirtualTime + let signal: Signal + + init(_ time: VirtualTime, _ signal: Signal) { + self.time = time + self.signal = signal + } +} + +// MARK: - Equatable conformance + +extension TestEvent: Equatable where Signal: Equatable {} + +// MARK: - CustomDebugStringConvertible conformance + +extension TestEvent: CustomDebugStringConvertible { + public var debugDescription: String { + return "SignalEvent(\(time), \(signal))" + } +} diff --git a/Sources/EntwineTest/TestScheduler/TestScheduler.swift b/Sources/EntwineTest/TestScheduler/TestScheduler.swift new file mode 100644 index 0000000..916545b --- /dev/null +++ b/Sources/EntwineTest/TestScheduler/TestScheduler.swift @@ -0,0 +1,232 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +// Based on RxSwift's `TestScheduler` +// Copyright © 2015 Krunoslav Zaher All rights reserved. +// https://github.com/ReactiveX/RxSwift + +import Entwine +import Combine + +// MARK: - TestScheduler definition + + +/// A `Scheduler` thats uses `VirtualTime` to schedule its tasks. +/// +/// A special, non thread-safe scheduler for testing operators that require a scheduler without introducing +/// real concurrency. Faciliates a recreatable sequence of tasks executed within 'virtual time'. +/// +public class TestScheduler { + + /// Configuration values for a`TestScheduler` test run. + public struct Configuration { + /// Determines if the scheduler starts the test immediately + public var pausedOnStart = false + /// Absolute `VirtualTime`at which `Publisher` factory is invoked. + public var created: VirtualTime = 100 + /// Absolute `VirtualTime` at which initialised `Publisher` is subscribed to. + public var subscribed: VirtualTime = 200 + /// Absolute `VirtualTime` at which subscription to `Publisher` is cancelled. + public var cancelled: VirtualTime = 900 + /// Options for the generated `TestableSubscriber` + public var subscriberOptions = TestableSubscriberOptions.default + + public static let `default` = Configuration() + } + + private var currentTime = VirtualTime(0) + private var lastTaskId = -1 + private var schedulerQueue: PriorityQueue + + /// Initialises the scheduler with the given commencement time + public init(initialClock: VirtualTime = 0) { + self.schedulerQueue = PriorityQueue(ascending: true, startingValues: []) + self.currentTime = initialClock + } + + /// Schedules the creation and subscription of an arbitrary `Publisher` to a `TestableSubscriber`, and + /// finally the subscription's subsequent cancellation. + /// + /// The default `Configuration`: + /// - Creates the publisher (executes the supplied publisher factory) at `100` + /// - Subscribes to the publisher at `200` + /// - Cancels the subscription at `900` + /// - Starts the scheduler immediately. + /// - Uses `TestableSubscriberOptions.default` for the subscriber configuration. + /// + /// - Parameters: + /// - configuration: The parameters of the test subscription including scheduling details + /// - create: A factory function that returns the publisher to be subscribed to + /// - Returns: A `TestableSubscriber` that contains, or is scheduled to contain, the output of the publisher subscription. + public func start(configuration: Configuration = .default, create: @escaping () -> P) -> TestableSubscriber { + + var subscriber = createTestableSubscriber(P.Output.self, P.Failure.self, options: configuration.subscriberOptions) + var source: AnyPublisher! + + schedule(after: configuration.created, tolerance: minimumTolerance, options: nil) { + source = create().eraseToAnyPublisher() + } + schedule(after: configuration.subscribed, tolerance: minimumTolerance, options: nil) { + source.subscribe(subscriber) + } + schedule(after: configuration.cancelled, tolerance: minimumTolerance, options: nil) { + subscriber.cancel() + } + + guard !configuration.pausedOnStart else { + return subscriber + } + + defer { resume() } + + return subscriber + } + + /// Initialises a `TestablePublisher` with events scheduled in absolute time. + /// + /// Creates a `TestablePublisher` and schedules the supplied `TestSequence` to occur in + /// absolute time. Sequence elements with virtual times in the 'past' will be ignored. + /// + /// - Warning: This method will produce an assertion failure if the supplied `TestSequence` includes + /// a `Signal.subscription` element. Subscription time is dictated by the subscriber and can not be + /// predetermined by the publisher. + /// + /// - Parameter sequence: The sequence of values the publisher should produce + /// - Returns: A `TestablePublisher` loaded with the supplied `TestSequence`. + public func createAbsoluteTestablePublisher(_ sequence: TestSequence) -> TestablePublisher { + return TestablePublisher(testScheduler: self, behavior: .absolute, testSequence: sequence) + } + + /// Initialises a `TestablePublisher` with events scheduled in relative time. + /// + /// Creates a `TestablePublisher` and schedules the supplied `TestSequence` to occur in + /// absolute time. + /// + /// - Warning: This method will produce an assertion failure if the supplied `TestSequence` includes + /// a `Signal.subscription` element. Subscription time is dictated by the subscriber and can not be + /// predetermined by the publisher. + /// + /// - Parameter sequence: The sequence of values the publisher should produce + /// - Returns: A `TestablePublisher` loaded with the supplied `TestSequence`. + public func createRelativeTestablePublisher(_ sequence: TestSequence) -> TestablePublisher { + return TestablePublisher(testScheduler: self, behavior: .relative, testSequence: sequence) + } + + + /// Produces a `TestableSubscriber` pre-populated with this scheduler. + /// + /// - Parameters: + /// - inputType: The `Input` associated type for the produced `Subscriber` + /// - failureType: The `Failure` associated type for the produced `Subscriber` + /// - options: Behavior options for the produced `Subscriber` + /// - Returns: A configured `TestableSubscriber`. + public func createTestableSubscriber(_ inputType: Input.Type, _ failureType: Failure.Type, options: TestableSubscriberOptions = .default) -> TestableSubscriber { + return TestableSubscriber(scheduler: self, options: options) + } + + /// Performs all the actions in the scheduler's queue, in time order followed by submission order, until no + /// more actions remain. + public func resume() { + while let next = findNext() { + if next.time > currentTime { + currentTime = next.time + } + next.action() + schedulerQueue.remove(next) + } + } + + func reset(initialClock: VirtualTime = 0) { + self.schedulerQueue = PriorityQueue(ascending: true, startingValues: []) + self.currentTime = initialClock + self.lastTaskId = -1 + } + + private func findNext() -> TestSchedulerTask? { + schedulerQueue.peek() + } + + private func nextTaskId() -> Int { + lastTaskId += 1 + return lastTaskId + } +} + +// MARK: - TestScheduler Scheduler conformance + +extension TestScheduler: Scheduler { + + public typealias SchedulerTimeType = VirtualTime + public typealias SchedulerOptions = Never + + public var now: VirtualTime { currentTime } + + public var minimumTolerance: VirtualTimeInterval { 1 } + + public func schedule(options: Never?, _ action: @escaping () -> Void) { + schedulerQueue.push(TestSchedulerTask(id: nextTaskId(), time: currentTime, action: action)) + } + + public func schedule(after date: VirtualTime, tolerance: VirtualTimeInterval, options: Never?, _ action: @escaping () -> Void) { + schedulerQueue.push(TestSchedulerTask(id: nextTaskId(), time: date, action: action)) + } + + public func schedule(after date: VirtualTime, interval: VirtualTimeInterval, tolerance: VirtualTimeInterval, options: Never?, _ action: @escaping () -> Void) -> Cancellable { + let task = TestSchedulerTask(id: nextTaskId(), time: date, action: action) + schedulerQueue.push(task) + return AnyCancellable { + self.schedulerQueue.remove(task) + } + } +} + +// MARK: - TestSchedulerTask definition + +struct TestSchedulerTask { + + typealias Action = () -> Void + + let id: Int + let time: VirtualTime + let action: Action + + init(id: Int, time: VirtualTime, action: @escaping Action) { + self.id = id + self.time = time + self.action = action + } +} + +// MARK: - TestSchedulerTask Comparable conformance + +extension TestSchedulerTask: Comparable { + + static func < (lhs: TestSchedulerTask, rhs: TestSchedulerTask) -> Bool { + (lhs.time, lhs.id) < (rhs.time, rhs.id) + } + + static func == (lhs: TestSchedulerTask, rhs: TestSchedulerTask) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/EntwineTest/TestScheduler/VirtualTime.swift b/Sources/EntwineTest/TestScheduler/VirtualTime.swift new file mode 100644 index 0000000..e61ab3b --- /dev/null +++ b/Sources/EntwineTest/TestScheduler/VirtualTime.swift @@ -0,0 +1,117 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +// MARK: - VirtualTime value definition + +/// Unit of virtual time consumed by the `TestScheduler` +public struct VirtualTime: Hashable { + + internal var _time: Int + + public init(_ time: Int) { + _time = time + } +} + +// MARK: - SignedNumeric conformance + +extension VirtualTime: SignedNumeric { + + public typealias Magnitude = Int + + public var magnitude: Int { Int(_time.magnitude) } + + public init?(exactly source: T) where T : BinaryInteger { + guard let value = Int(exactly: source) else { return nil } + self.init(value) + } + + public static func * (lhs: VirtualTime, rhs: VirtualTime) -> VirtualTime { + .init(lhs._time * rhs._time) + } + + public static func *= (lhs: inout VirtualTime, rhs: VirtualTime) { + lhs._time *= rhs._time + } + + public static func + (lhs: VirtualTime, rhs: VirtualTime) -> VirtualTime { + .init(lhs._time + rhs._time) + } + + public static func - (lhs: VirtualTime, rhs: VirtualTime) -> VirtualTime { + .init(lhs._time - rhs._time) + } + + public static func += (lhs: inout VirtualTime, rhs: VirtualTime) { + lhs._time += rhs._time + } + + public static func -= (lhs: inout VirtualTime, rhs: VirtualTime) { + lhs._time -= rhs._time + } +} + +// MARK: - Strideable conformance + +extension VirtualTime: Strideable { + + public typealias Stride = VirtualTimeInterval + + public func distance(to other: VirtualTime) -> VirtualTimeInterval { + .init(other._time - _time) + } + + public func advanced(by n: VirtualTimeInterval) -> VirtualTime { + .init(_time + n._duration) + } +} + +// MARK: - ExpressibleByIntegerLiteral conformance + +extension VirtualTime: ExpressibleByIntegerLiteral { + + public typealias IntegerLiteralType = Int + + public init(integerLiteral value: Int) { + self.init(value) + } +} + +// MARK: - CustomDebugStringConvertible conformance + +extension VirtualTime: CustomDebugStringConvertible { + public var debugDescription: String { + return "\(_time)" + } +} + +// MARK: - Int initializer + +extension Int { + init(_ value: VirtualTime) { + self.init(value._time) + } +} diff --git a/Sources/EntwineTest/TestScheduler/VirtualTimeInterval.swift b/Sources/EntwineTest/TestScheduler/VirtualTimeInterval.swift new file mode 100644 index 0000000..dad119b --- /dev/null +++ b/Sources/EntwineTest/TestScheduler/VirtualTimeInterval.swift @@ -0,0 +1,157 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +// MARK: - VirtualTimeInterval value definition + +/// Unit of relative virtual time consumed by the `TestScheduler` +public struct VirtualTimeInterval { + + internal var _duration: Int + + public init(_ duration: Int) { + _duration = duration + } +} + +// MARK: - SignedNumeric conformance + +extension VirtualTimeInterval: SignedNumeric { + + public typealias Magnitude = Int + + public var magnitude: Int { Int(_duration.magnitude) } + + public init?(exactly source: T) where T : BinaryInteger { + guard let value = Int(exactly: source) else { return nil } + self.init(value) + } + + public static func * (lhs: VirtualTimeInterval, rhs: VirtualTimeInterval) -> VirtualTimeInterval { + .init(lhs._duration * rhs._duration) + } + + public static func *= (lhs: inout VirtualTimeInterval, rhs: VirtualTimeInterval) { + lhs._duration *= rhs._duration + } + + public static func + (lhs: VirtualTimeInterval, rhs: VirtualTimeInterval) -> VirtualTimeInterval { + .init(lhs._duration + rhs._duration) + } + + public static func - (lhs: VirtualTimeInterval, rhs: VirtualTimeInterval) -> VirtualTimeInterval { + .init(lhs._duration - rhs._duration) + } + + public static func += (lhs: inout VirtualTimeInterval, rhs: VirtualTimeInterval) { + lhs._duration += rhs._duration + } + + public static func -= (lhs: inout VirtualTimeInterval, rhs: VirtualTimeInterval) { + lhs._duration -= rhs._duration + } +} + +// MARK: - Comparable conformance + +extension VirtualTimeInterval: Comparable { + + public static func < (lhs: VirtualTimeInterval, rhs: VirtualTimeInterval) -> Bool { + lhs._duration < rhs._duration + } +} + +// MARK: - ExpressibleByIntegerLiteral conformance + +extension VirtualTimeInterval: ExpressibleByIntegerLiteral { + + public typealias IntegerLiteralType = Int + + public init(integerLiteral value: Int) { + self.init(value) + } +} + +// MARK: - SchedulerTimeIntervalConvertible conformance + +extension VirtualTimeInterval: SchedulerTimeIntervalConvertible { + + public static func seconds(_ s: Int) -> VirtualTimeInterval { + return .init(s) + } + + public static func seconds(_ s: Double) -> VirtualTimeInterval { + return .init(Int(s + 0.5)) + } + + public static func milliseconds(_ ms: Int) -> VirtualTimeInterval { + .seconds(Double(ms) / 1e+3) + } + + public static func microseconds(_ us: Int) -> VirtualTimeInterval { + .seconds(Double(us) / 1e+6) + } + + public static func nanoseconds(_ ns: Int) -> VirtualTimeInterval { + .seconds(Double(ns) / 1e+9) + } +} + +// MARK: - VirtualTime artithmetic + +extension VirtualTimeInterval { + + public static func * (lhs: VirtualTime, rhs: VirtualTimeInterval) -> VirtualTime { + .init(lhs._time * rhs._duration) + } + + public static func *= (lhs: inout VirtualTime, rhs: VirtualTimeInterval) { + lhs._time *= rhs._duration + } + + public static func + (lhs: VirtualTime, rhs: VirtualTimeInterval) -> VirtualTime { + .init(lhs._time + rhs._duration) + } + + public static func - (lhs: VirtualTime, rhs: VirtualTimeInterval) -> VirtualTime { + .init(lhs._time - rhs._duration) + } + + public static func += (lhs: inout VirtualTime, rhs: VirtualTimeInterval) { + lhs._time += rhs._duration + } + + public static func -= (lhs: inout VirtualTime, rhs: VirtualTimeInterval) { + lhs._time -= rhs._duration + } +} + +// MARK: - Int initializer + +extension Int { + init(_ value: VirtualTimeInterval) { + self.init(value._duration) + } +} diff --git a/Sources/EntwineTest/TestSequence.swift b/Sources/EntwineTest/TestSequence.swift new file mode 100644 index 0000000..3ffa7f2 --- /dev/null +++ b/Sources/EntwineTest/TestSequence.swift @@ -0,0 +1,107 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Entwine + +// MARK: - TestSequence definition + +/// A collection of time-stamped `Signal`s +public struct TestSequence { + + private var contents: [Element] + + /// Initializes the `TestSequence` with a series of tuples of the format `(VirtualTime, Signal)` + public init(_ elements: S) where S.Element == Element { + self.contents = Array(elements) + } + + /// Initializes an empty `TestSequence` + public init() { + self.contents = [Element]() + } +} + +// MARK: - Sequence conformance + +extension TestSequence: Sequence { + + public typealias Iterator = IndexingIterator<[Element]> + public typealias Element = (VirtualTime, Signal) + + public __consuming func makeIterator() -> IndexingIterator<[(VirtualTime, Signal)]> { + contents.makeIterator() + } +} + +// MARK: - RangeReplaceableCollection conformance + +extension TestSequence: RangeReplaceableCollection { + + public typealias Index = Int + + public subscript(position: Index) -> Element { + get { contents[position] } + set { contents[position] = newValue } + } + + public var startIndex: Index { + contents.startIndex + } + + public var endIndex: Index { + contents.endIndex + } + + public func index(after i: Index) -> Index { + contents.index(after: i) + } + + public mutating func replaceSubrange(_ subrange: R, with newElements: C) where Element == C.Element, Index == R.Bound { + contents.replaceSubrange(subrange, with: newElements) + } +} + +// MARK: - ExpressibleByArrayLiteral conformance + +extension TestSequence: ExpressibleByArrayLiteral { + + public typealias ArrayLiteralElement = Element + + public init(arrayLiteral elements: Element...) { + self.init(elements) + } +} + +// MARK: - Equatable conformance + +extension TestSequence: Equatable where Input: Equatable, Failure: Equatable { + + private var events: [TestEvent>] { + map { TestEvent($0.0, $0.1) } + } + + public static func == (lhs: TestSequence, rhs: TestSequence) -> Bool { + lhs.events == rhs.events + } +} diff --git a/Sources/EntwineTest/TestablePublisher/TestablePublisher.swift b/Sources/EntwineTest/TestablePublisher/TestablePublisher.swift new file mode 100644 index 0000000..156fd9a --- /dev/null +++ b/Sources/EntwineTest/TestablePublisher/TestablePublisher.swift @@ -0,0 +1,103 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine +import Entwine +import Foundation + +// MARK: - Behavior value definition + +enum TestablePublisherBehavior { case absolute, relative } + +// MARK: - Publisher definition + +/// A `Publisher` that produces the elements provided in a `TestSequence`. +/// +/// Initializable using the factory methods on `TestScheduler` +public struct TestablePublisher: Publisher { + + private let testScheduler: TestScheduler + private let testSequence: TestSequence + private let behavior: TestablePublisherBehavior + + init(testScheduler: TestScheduler, behavior: TestablePublisherBehavior, testSequence: TestSequence) { + self.testScheduler = testScheduler + self.testSequence = testSequence + self.behavior = behavior + } + + public func receive(subscriber: S) where S.Failure == Failure, S.Input == Output { + subscriber.receive(subscription: + TestablePublisherSubscription( + sink: subscriber, testScheduler: testScheduler, behavior: behavior, testSequence: testSequence)) + } +} + +// MARK: - Subscription definition + +fileprivate final class TestablePublisherSubscription: Subscription { + + private let linkedList = LinkedList.empty + private let queue: SinkQueue + private var cancellables = [AnyCancellable]() + + init(sink: Sink, testScheduler: TestScheduler, behavior: TestablePublisherBehavior, testSequence: TestSequence) { + + self.queue = SinkQueue(sink: sink) + + testSequence.forEach { (time, signal) in + + guard behavior == .relative || testScheduler.now <= time else { return } + let due = behavior == .relative ? testScheduler.now + time : time + + switch signal { + case .subscription: + assertionFailure("Illegal input. A `.subscription` event scheduled at \(time) will be ignored. Only a Subscriber can initiate a Subscription.") + break + case .input(let value): + let cancellable = testScheduler.schedule(after: due, interval: 0) { [unowned self] in + _ = self.queue.enqueue(value) + } + cancellables.append(AnyCancellable { cancellable.cancel() }) + case .completion(let completion): + let cancellable = testScheduler.schedule(after: due, interval: 0) { [unowned self] in + self.queue.expediteCompletion(completion) + } + cancellables.append(AnyCancellable { cancellable.cancel() }) + } + } + } + + deinit { + cancellables.forEach { $0.cancel() } + } + + func request(_ demand: Subscribers.Demand) { + _ = queue.requestDemand(demand) + } + + func cancel() { + queue.expediteCompletion(.finished) + } +} diff --git a/Sources/EntwineTest/TestableSubscriber/DemandLedger.swift b/Sources/EntwineTest/TestableSubscriber/DemandLedger.swift new file mode 100644 index 0000000..2ca8b49 --- /dev/null +++ b/Sources/EntwineTest/TestableSubscriber/DemandLedger.swift @@ -0,0 +1,156 @@ +// +// Entwine +// https://github.com/tcldr/Entwine +// +// Copyright © 2019 Tristan Celder. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Combine + +// MARK: - TestSequence definition + +/// A sequence of `Subscribers.Demand` transactions. +/// +/// `DemandLedger`'s can be compared to see if they match expectations. +public struct DemandLedger where Time.Stride : SchedulerTimeIntervalConvertible { + + /// The kind of transcation for a `DemandLedger` + /// + /// - `.credit(amount:)`: The raise in authorized demand issued by a `Subscriber`. + /// - `.debit(authorized:)`: The consumption of credit by an upstream `Publisher`. The debit is only considered authorised if the overall + /// credit is greater or equal to the total debit over the lifetime of a subscription. A `debit` always has an implicit amount of `1`. + public enum Transaction: Equatable where Time.Stride : SchedulerTimeIntervalConvertible { + case credit(amount: Subscribers.Demand) + case debit(authorized: Bool) + } + + private var contents: [Element] + + + /// Initializes a pre-populated `DemandLedger` + /// - Parameter elements: A sequence of elements of the format `(VirtualTime, Subscribers.Demand, Transaction