From 82e19258da973745823a4c0a19b8912b91597e1c Mon Sep 17 00:00:00 2001 From: Florian Fittschen Date: Thu, 6 Aug 2020 18:05:51 +0200 Subject: [PATCH] Add TimelaneCombineX --- .../contents.xcworkspacedata | 7 + LICENSE | 21 ++ Package.resolved | 52 +++ Package.swift | 37 +++ README.md | 37 ++- .../TimelaneCombineX/PublishedOnLane.swift | 41 +++ .../TimelaneCombineX/TimelaneCombineX.swift | 167 ++++++++++ .../TimelaneCombineXTests/TestPublisher.swift | 37 +++ .../TimelaneCombineXTests.swift | 312 ++++++++++++++++++ 9 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 LICENSE create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 Sources/TimelaneCombineX/PublishedOnLane.swift create mode 100644 Sources/TimelaneCombineX/TimelaneCombineX.swift create mode 100644 Tests/TimelaneCombineXTests/TestPublisher.swift create mode 100644 Tests/TimelaneCombineXTests/TimelaneCombineXTests.swift diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..29f7fb1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Sixt SE. + +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. \ No newline at end of file diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..7b90007 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,52 @@ +{ + "object": { + "pins": [ + { + "package": "CombineX", + "repositoryURL": "https://github.com/cx-org/CombineX", + "state": { + "branch": null, + "revision": "9ce154b155643ec8939d84cb6ae00c331ff3eca0", + "version": "0.2.1" + } + }, + { + "package": "Nimble", + "repositoryURL": "https://github.com/Quick/Nimble.git", + "state": { + "branch": null, + "revision": "f8657642dfdec9973efc79cc68bcef43a653a2bc", + "version": "8.0.2" + } + }, + { + "package": "Quick", + "repositoryURL": "https://github.com/Quick/Quick.git", + "state": { + "branch": null, + "revision": "09b3becb37cb2163919a3842a4c5fa6ec7130792", + "version": "2.2.1" + } + }, + { + "package": "Semver", + "repositoryURL": "https://github.com/ddddxxx/Semver.git", + "state": { + "branch": null, + "revision": "6094e6f23a02b52b5d211fd114a4750c4f3ecef3", + "version": "0.2.1" + } + }, + { + "package": "TimelaneCore", + "repositoryURL": "https://github.com/icanzilb/TimelaneCore", + "state": { + "branch": null, + "revision": "35437d370a6127d14f728065b3d4e3e9cf65e6a0", + "version": "1.0.12" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..9396154 --- /dev/null +++ b/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version:5.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. +import PackageDescription + +let package = Package( + name: "TimelaneCombineX", + platforms: [ + .macOS(.v10_14), + .iOS(.v12), + .tvOS(.v12), + .watchOS(.v5) + ], + products: [ + .library( + name: "TimelaneCombineX", + targets: ["TimelaneCombineX"]), + ], + dependencies: [ + .package(url: "https://github.com/icanzilb/TimelaneCore", from: "1.0.0"), + .package(url: "https://github.com/cx-org/CombineX", from: "0.2.1") + ], + targets: [ + .target( + name: "TimelaneCombineX", + dependencies: [ + "TimelaneCore", + "CombineX" + ]), + .testTarget( + name: "TimelaneCombineXTests", + dependencies: [ + "TimelaneCombineX", + "TimelaneCoreTestUtils" + ]), + ], + swiftLanguageVersions: [.v5] +) diff --git a/README.md b/README.md index 5032869..cc801dd 100644 --- a/README.md +++ b/README.md @@ -1 +1,36 @@ -# TimelaneCombineX \ No newline at end of file +

Timelane + CombineX

+

+ TimelaneCombineX +

+ +**TimelaneCombineX** is a port of [TimelaneCombine](https://github.com/icanzilb/TimelaneCombine) to provide CombineX bindings for profiling [CombineX](https://github.com/cx-org/CombineX) code with the [Timelane](http://timelane.tools) Instrument. + +#### Table of Contents: + +- [Installation](#Installation) +- [Usage](#Usage) +- [License](#License) + +# Installation + +## Swift Package Manager + +I . Automatically in Xcode: + + - Click **File > Swift Packages > Add Package Dependency...** + - Use the package URL `https://github.com/Sixt/TimelaneCombineX` to add TimelaneCombineX to your project. + +II . Manually in your **Package.swift** file add: + +```swift +.package(url: "https://github.com/Sixt/TimelaneCombineX", from: "1.0.0") +``` + +# Usage + +For usage instructions, please refer to the original [TimelaneCombine](https://github.com/icanzilb/TimelaneCombine) repository. + +# License + +Copyright (c) Sixt SE 2020 +This package is provided under the MIT License. diff --git a/Sources/TimelaneCombineX/PublishedOnLane.swift b/Sources/TimelaneCombineX/PublishedOnLane.swift new file mode 100644 index 0000000..b6c1cea --- /dev/null +++ b/Sources/TimelaneCombineX/PublishedOnLane.swift @@ -0,0 +1,41 @@ +import CombineX +import TimelaneCore + +/// Property wrapper that offers a publisher for the given property +/// **and** creates a Timelane lane for it. +@propertyWrapper public class PublishedOnLane { + @Published private var value: Value + private let laneName: String + private let filter: Timelane.LaneTypeOptions + + /// Gets or sets the value of this property. + public var wrappedValue: Value { + get { self.value } + set { self.value = newValue } + } + + /// Gets the lane-enabled `Publisher` for this property. + public var projectedValue: AnyPublisher { + return self.$value.lane(laneName, filter: filter).eraseToAnyPublisher() + } + + /// Creates a `PublishedOnLane` wrapper. + /// - Parameters: + /// - initialValue: Value to wrap; usually implicit. + /// - name: The name of this lane; defaults to the + /// type of this property if not provided. + public init(wrappedValue initialValue: Value, + _ name: String? = nil, + filter: Timelane.LaneTypeOptions = .all) { + self.value = initialValue + self.laneName = name ?? "\(initialValue.self)" + self.filter = filter + } + + public init(wrappedValue initialValue: Value, + _ name: String? = nil) { + self.value = initialValue + self.laneName = name ?? "\(initialValue.self)" + self.filter = .all + } +} diff --git a/Sources/TimelaneCombineX/TimelaneCombineX.swift b/Sources/TimelaneCombineX/TimelaneCombineX.swift new file mode 100644 index 0000000..98022bd --- /dev/null +++ b/Sources/TimelaneCombineX/TimelaneCombineX.swift @@ -0,0 +1,167 @@ +import CombineX +import CXFoundation +import Foundation +import TimelaneCore + +extension Publishers { + public class TimelanePublisher: Publisher where S: Scheduler { + public typealias Output = Upstream.Output + public typealias Failure = Upstream.Failure + + private let upstream: Upstream + + private let name: String? + private let filter: Timelane.LaneTypeOptions + private let source: String + private let scheduler: S + private let transformValue: (Upstream.Output) -> String + private let logger: Timelane.Logger + + public init(upstream: Upstream, + name: String?, + filter: Timelane.LaneTypeOptions, + source: String, + scheduler: S, + transformValue: @escaping (Upstream.Output) -> String, + logger: @escaping Timelane.Logger) { + self.upstream = upstream + self.name = name + self.filter = filter + self.source = source + self.scheduler = scheduler + self.transformValue = transformValue + self.logger = logger + } + + public func receive(subscriber: S) where Failure == S.Failure, Output == S.Input { + let filter = self.filter + let source = self.source + let scheduler = self.scheduler + let subscription = Timelane.Subscription(name: name, logger: logger) + let transform = self.transformValue + + let sink = AnySubscriber( + receiveSubscription: { sub in + if filter.contains(.subscription) { + subscription.begin(source: source) + } + + subscriber.receive(subscription: sub) + }, + receiveValue: { value -> Subscribers.Demand in + if filter.contains(.event) { + subscription.event(value: .value(transform(value)), source: source) + } + + return subscriber.receive(value) + }, + receiveCompletion: { completion in + if filter.contains(.subscription) { + switch completion { + case .finished: + subscription.end(state: .completed) + case .failure(let error): + subscription.end(state: .error(error.localizedDescription)) + } + } + + if filter.contains(.event) { + switch completion { + case .finished: + subscription.event(value: .completion, source: source) + case .failure(let error): + subscription.event(value: .error(error.localizedDescription), source: source) + } + } + + subscriber.receive(completion: completion) + } + ) + + upstream + .handleEvents(receiveCancel: { + // Sometimes a "cancel" event preceeds "finished" so we seem to + // need this hack below to make sure "finished" goes out first. + scheduler.schedule { + // Cancelling the subscription + if filter.contains(.subscription) { + subscription.end(state: .cancelled) + } + if filter.contains(.event) { + subscription.event(value: .cancelled, source: source) + } + } + }) + .subscribe(sink) + } + } +} + +extension Publisher { + + /// The `lane` operator logs the subscription and its events to the Timelane Instrument. + /// + /// - Note: You can download the Timelane Instrument from http://timelane.tools + /// - Parameters: + /// - name: A name for the lane when visualized in Instruments + /// - filter: Which events to log subscriptions or data events. + /// For example for a subscription on a subject you might be interested only in data events. + /// - transformValue: An optional closure to format the subscription values for displaying in Instruments. + /// You can not only prettify the values but also change them completely, e.g. for arrays you can + /// it might be more useful to report the count of elements if there are a lot of them. + /// - value: The value emitted by the subscription + public func lane(_ name: String, + filter: Timelane.LaneTypeOptions = .all, + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line, + scheduler: CXWrappers.DispatchQueue = DispatchQueue.main.cx, + transformValue: @escaping (_ value: Output) -> String = { String(describing: $0) }, + logger: @escaping Timelane.Logger = Timelane.defaultLogger) + -> Publishers.TimelanePublisher { + + let fileName = file.description.components(separatedBy: "/").last! + let source = "\(fileName):\(line) - \(function)" + + return Publishers.TimelanePublisher(upstream: self, + name: name, + filter: filter, + source: source, + scheduler: scheduler, + transformValue: transformValue, + logger: logger) + } + + /// The `lane` operator logs the subscription and its events to the Timelane Instrument. + /// + /// - Note: You can download the Timelane Instrument from http://timelane.tools + /// - Parameters: + /// - name: A name for the lane when visualized in Instruments + /// - filter: Which events to log subscriptions or data events. + /// For example for a subscription on a subject you might be interested only in data events. + /// - transformValue: An optional closure to format the subscription values for displaying in Instruments. + /// You can not only prettify the values but also change them completely, e.g. for arrays you can + /// it might be more useful to report the count of elements if there are a lot of them. + /// - value: The value emitted by the subscription + public func lane(_ name: String, + filter: Timelane.LaneTypeOptions = .all, + file: StaticString = #file, + function: StaticString = #function, + line: UInt = #line, + scheduler: S, + transformValue: @escaping (_ value: Output) -> String = { String(describing: $0) }, + logger: @escaping Timelane.Logger = Timelane.defaultLogger) + -> Publishers.TimelanePublisher where S: Scheduler { + + let fileName = file.description.components(separatedBy: "/").last! + let source = "\(fileName):\(line) - \(function)" + + return Publishers.TimelanePublisher(upstream: self, + name: name, + filter: filter, + source: source, + scheduler: scheduler, + transformValue: transformValue, + logger: logger) + } +} diff --git a/Tests/TimelaneCombineXTests/TestPublisher.swift b/Tests/TimelaneCombineXTests/TestPublisher.swift new file mode 100644 index 0000000..7c8c852 --- /dev/null +++ b/Tests/TimelaneCombineXTests/TestPublisher.swift @@ -0,0 +1,37 @@ +import CombineX +import Foundation + +extension Publishers { + class TestPublisherSubscription: Subscription { + func request(_ demand: Subscribers.Demand) { } + func cancel() { } + } + + class TestPublisher: Publisher { + typealias Output = String + typealias Failure = Error + + let duration: TimeInterval + let error: Error? + + init(duration: TimeInterval, error: Error? = nil) { + self.duration = duration + self.error = error + } + + func receive(subscriber: S) where S : Subscriber, Publishers.TestPublisher.Failure == S.Failure, Publishers.TestPublisher.Output == S.Input { + _ = subscriber.receive("Hello") + let error = self.error + + subscriber.receive(subscription: TestPublisherSubscription()) + + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + if let error = error { + subscriber.receive(completion: .failure(error)) + } else { + subscriber.receive(completion: .finished) + } + } + } + } +} diff --git a/Tests/TimelaneCombineXTests/TimelaneCombineXTests.swift b/Tests/TimelaneCombineXTests/TimelaneCombineXTests.swift new file mode 100644 index 0000000..52f0e8c --- /dev/null +++ b/Tests/TimelaneCombineXTests/TimelaneCombineXTests.swift @@ -0,0 +1,312 @@ +import CombineX +@testable import TimelaneCombineX +@testable import TimelaneCore +import TimelaneCoreTestUtils +import XCTest + +final class TimelaneCombineTests: XCTestCase { + + /// Test the events emitted by a sync array publisher + func testEmitsEventsFromCompletingPublisher() { + let recorder = TestLog() + Timelane.Subscription.didEmitVersion = true + + _ = [1, 2, 3].cx.publisher + .lane("Test Subscription", filter: .event, logger: recorder.log) + .sink(receiveValue: {_ in }) + + XCTAssertEqual(recorder.logged.count, 4) + guard recorder.logged.count == 4 else { + return + } + + XCTAssertEqual(recorder.logged[0].outputTldr, "Output, Test Subscription, 1") + XCTAssertEqual(recorder.logged[1].outputTldr, "Output, Test Subscription, 2") + XCTAssertEqual(recorder.logged[2].outputTldr, "Output, Test Subscription, 3") + + XCTAssertEqual(recorder.logged[3].type, "Completed") + XCTAssertEqual(recorder.logged[3].subscription, "Test Subscription") + } + + /// Test the events emitted by a subject + func testEmitsEventsFromNonCompletingPublisher() { + let recorder = TestLog() + Timelane.Subscription.didEmitVersion = true + + let subject = CurrentValueSubject(0) + let cancellable = subject + .lane("Test Subscription", filter: .event, logger: recorder.log) + .sink(receiveValue: {_ in }) + + XCTAssertNotNil(cancellable) + + XCTAssertEqual(recorder.logged.count, 1) + guard recorder.logged.count == 1 else { + return + } + + XCTAssertEqual(recorder.logged[0].outputTldr, "Output, Test Subscription, 0") + + subject.send(1) + subject.send(2) + subject.send(3) + + XCTAssertEqual(recorder.logged.count, 4) + guard recorder.logged.count == 4 else { + return + } + + XCTAssertEqual(recorder.logged[1].outputTldr, "Output, Test Subscription, 1") + XCTAssertEqual(recorder.logged[2].outputTldr, "Output, Test Subscription, 2") + XCTAssertEqual(recorder.logged[3].outputTldr, "Output, Test Subscription, 3") + } + + /// Test the cancelled event + func testEmitsEventsFromCancelledPublisher() { + let recorder = TestLog() + Timelane.Subscription.didEmitVersion = true + + let subject = CurrentValueSubject(0) + var cancellable: AnyCancellable? = subject + .lane("Test Subscription", filter: [.event], logger: recorder.log) + .sink(receiveValue: {_ in }) + + XCTAssertNotNil(cancellable) + + XCTAssertEqual(recorder.logged.count, 1) + guard recorder.logged.count == 1 else { + return + } + + XCTAssertEqual(recorder.logged[0].outputTldr, "Output, Test Subscription, 0") + + cancellable?.cancel() + cancellable = nil + + // Wait a beat before checking for cancelled event + let fauxExpectation = expectation(description: "Just waiting a beat") + DispatchQueue.main.async(execute: fauxExpectation.fulfill) + wait(for: [fauxExpectation], timeout: 1) + + XCTAssertEqual(recorder.logged.count, 2) + guard recorder.logged.count == 2 else { + return + } + + XCTAssertEqual(recorder.logged[1].type, "Cancelled") + } + + enum TestError: LocalizedError { + case test + var errorDescription: String? { + return "Error description" + } + } + + /// Test error event + func testEmitsEventsFromFailedPublisher() { + let recorder = TestLog() + Timelane.Subscription.didEmitVersion = true + + let subject = CurrentValueSubject(0) + let cancellable = subject + .lane("Test Subscription", filter: .event, logger: recorder.log) + .sink(receiveCompletion: { _ in }) { _ in } + + XCTAssertNotNil(cancellable) + + XCTAssertEqual(recorder.logged.count, 1) + guard recorder.logged.count == 1 else { + return + } + + XCTAssertEqual(recorder.logged[0].outputTldr, "Output, Test Subscription, 0") + + subject.send(completion: .failure(.test)) + + XCTAssertEqual(recorder.logged.count, 2) + guard recorder.logged.count == 2 else { + return + } + + XCTAssertEqual(recorder.logged[1].type, "Error") + XCTAssertEqual(recorder.logged[1].value, "Error description") + } + + /// Test subscription + func testEmitsSubscription() { + let recorder = TestLog() + Timelane.Subscription.didEmitVersion = true + + let subject = CurrentValueSubject(0) + let cancellable = subject + .lane("Test Subscription", filter: .subscription, logger: recorder.log) + .sink(receiveCompletion: { _ in }) { _ in } + + XCTAssertNotNil(cancellable) + + subject.send(1) + subject.send(2) + subject.send(3) + subject.send(completion: .finished) + + XCTAssertEqual(recorder.logged.count, 2) + guard recorder.logged.count == 2 else { + return + } + + XCTAssertEqual(recorder.logged[0].signpostType, "begin") + XCTAssertEqual(recorder.logged[0].subscribe, "Test Subscription") + + XCTAssertEqual(recorder.logged[1].signpostType, "end") + } + + /// Test formatting + func testFormatting() { + let recorder = TestLog() + Timelane.Subscription.didEmitVersion = true + + let subject = CurrentValueSubject(0) + let cancellable = subject + .lane("Test Subscription", filter: .event, transformValue: { _ in return "TEST" }, logger: recorder.log) + .sink(receiveCompletion: { _ in }) { _ in } + + XCTAssertNotNil(cancellable) + + subject.send(1) + + XCTAssertEqual(recorder.logged.count, 2) + guard recorder.logged.count == 2 else { + return + } + + XCTAssertEqual(recorder.logged[1].outputTldr, "Output, Test Subscription, TEST") + } + + /// Test multiple async subscriptions + func testMultipleSubscriptions() { + let recorder = TestLog() + var subscriptions = [AnyCancellable]() + + let initialSubscriptionCount = Timelane.Subscription.subscriptionCounter + Timelane.Subscription.didEmitVersion = true + + let testPublisher = Publishers.TestPublisher(duration: 1.0) + .lane("Test Subscription", filter: .event, transformValue: { _ in return "TEST" }, logger: recorder.log) + + testPublisher + .sink(receiveCompletion: { _ in }) { _ in } + .store(in: &subscriptions) + + testPublisher + .sink(receiveCompletion: { _ in }) { _ in } + .store(in: &subscriptions) + + DispatchQueue.global().async { + testPublisher + .sink(receiveCompletion: { _ in }) { _ in } + .store(in: &subscriptions) + } + + // Wait a beat before checking the recorder + let fauxExpectation = expectation(description: "Just waiting a beat") + DispatchQueue.main.asyncAfter(wallDeadline: .now() + 2) { + fauxExpectation.fulfill() + } + wait(for: [fauxExpectation], timeout: 3) + + XCTAssertEqual(recorder.logged.count, 6) + guard recorder.logged.count == 6 else { + return + } + + XCTAssertEqual(recorder.logged[0].outputTldr, "Output, Test Subscription, TEST") + XCTAssertEqual(recorder.logged[1].outputTldr, "Output, Test Subscription, TEST") + XCTAssertEqual(recorder.logged[2].outputTldr, "Output, Test Subscription, TEST") + + XCTAssertEqual(recorder.logged[3].outputTldr, "Completed, Test Subscription, ") + XCTAssertEqual(recorder.logged[3].id, "\(initialSubscriptionCount+1)") + XCTAssertEqual(recorder.logged[4].outputTldr, "Completed, Test Subscription, ") + XCTAssertEqual(recorder.logged[4].id, "\(initialSubscriptionCount+2)") + XCTAssertEqual(recorder.logged[5].outputTldr, "Completed, Test Subscription, ") + XCTAssertEqual(recorder.logged[5].id, "\(initialSubscriptionCount+3)") + } + + /// Test timelane does not affect the subscription events + func testPasstroughSubscriptionEvents() { + let recorder = TestLog() + Timelane.Subscription.didEmitVersion = true + + var recordedEvents = [String]() + _ = [1, 2, 3].cx.publisher + .lane("Test Subscription", filter: .event, transformValue: { _ in return "TEST" }, logger: recorder.log) + .handleEvents(receiveSubscription: { _ in + recordedEvents.append("Subscribed") + }, receiveOutput: { value in + recordedEvents.append("Value: \(value)") + }, receiveCompletion: { _ in + recordedEvents.append("Completed") + }) + .sink { _ in + // Nothing to do here + } + + XCTAssertEqual(recordedEvents, [ + "Subscribed", + "Value: 1", + "Value: 2", + "Value: 3", + "Completed" + ]) + } + + /// Test the events emitted by a sync array publisher + func testEmitsAfterReceiveSubscribe() { + let recorder = TestLog() + Timelane.Subscription.didEmitVersion = true + + var subscriptions = [AnyCancellable]() + + [1, 2, 3].cx.publisher + .lane("Pre Subscription", filter: .event, logger: recorder.log) + .subscribe(on: DispatchQueue.global().cx) + .receive(on: RunLoop.main.cx) + .lane("Post Subscription", filter: .event, logger: recorder.log) + .sink(receiveValue: {_ in }) + .store(in: &subscriptions) + + let fauxExpectation = expectation(description: "Just waiting a beat") + DispatchQueue.main.asyncAfter(wallDeadline: .now() + 2) { + fauxExpectation.fulfill() + } + wait(for: [fauxExpectation], timeout: 3) + + XCTAssertEqual(recorder.logged.count, 8) + guard recorder.logged.count == 8 else { + return + } + + XCTAssertTrue(recorder.logged.map({ $0.outputTldr }).contains("Output, Pre Subscription, 1")) + XCTAssertTrue(recorder.logged.map({ $0.outputTldr }).contains("Output, Pre Subscription, 2")) + XCTAssertTrue(recorder.logged.map({ $0.outputTldr }).contains("Output, Pre Subscription, 3")) + XCTAssertTrue(recorder.logged.map({ $0.outputTldr }).contains("Completed, Pre Subscription, ")) + + XCTAssertTrue(recorder.logged.map({ $0.outputTldr }).contains("Output, Post Subscription, 1")) + XCTAssertTrue(recorder.logged.map({ $0.outputTldr }).contains("Output, Post Subscription, 2")) + XCTAssertTrue(recorder.logged.map({ $0.outputTldr }).contains("Output, Post Subscription, 3")) + XCTAssertTrue(recorder.logged.map({ $0.outputTldr }).contains("Completed, Post Subscription, ")) + } + + + static var allTests = [ + ("testEmitsEventsFromCompletingPublisher", testEmitsEventsFromCompletingPublisher), + ("testEmitsEventsFromNonCompletingPublisher", testEmitsEventsFromNonCompletingPublisher), + ("testEmitsEventsFromCancelledPublisher", testEmitsEventsFromCancelledPublisher), + ("testEmitsEventsFromFailedPublisher", testEmitsEventsFromFailedPublisher), + ("testEmitsSubscription", testEmitsSubscription), + ("testFormatting", testFormatting), + ("testMultipleSubscriptions", testMultipleSubscriptions), + ("testPasstroughSubscriptionEvents", testPasstroughSubscriptionEvents), + ("testEmitsAfterReceiveSubscribe", testEmitsAfterReceiveSubscribe), + ] +}