diff --git a/.circleci/config.yml b/.circleci/config.yml index 0a57c6c..52f928e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,12 +4,12 @@ version: 2.1 anchors: - - &test_device "iPhone Xs" + - &test_device "iPhone 14" + - &test_device_os "16.2" - &clean_before_build true - - &test_output_folder test_output - &default_executor macos: - xcode: "14.0.0" + xcode: "14.2.0" env: global: @@ -36,11 +36,18 @@ commands: pod install --verbose test_main_project: + parameters: + simulator: + type: string + default: *test_device + os_version: + type: string + default: *test_device_os steps: - checkout - test_project_and_store_results: - project: "Flow.xcodeproj" - scheme: "Flow" + simulator: <> + os_version: <> test_example_project: parameters: @@ -54,30 +61,32 @@ commands: workspace: "Example.xcworkspace" scheme: "Example" path: <> - test_output_folder: *test_output_folder # We introduced two separate commands for projects and workspaces because we didn't find a generic and non-confusing way to introduce - # a condition to only pass either the project or the workspace environment argument to the fastlane scan + # a condition to only pass either the project or the workspace environment argument to the test output test_project_and_store_results: description: "Builds and tests a project and then stores the results of the tests as artifacts and test results report" parameters: - project: + simulator: type: string - scheme: + default: *test_device + os_version: type: string + default: *test_device_os steps: - run: - command: fastlane scan - environment: - SCAN_PROJECT: <> - SCAN_SCHEME: <> - SCAN_DEVICE: *test_device - SCAN_CLEAN: *clean_before_build + name: Run tests on iOS <> + command: | + xcodebuild -scheme Flow \ + -project Flow.xcodeproj \ + -destination "platform=iOS Simulator,OS=<>,name=<>" \ + build test \ + | xcpretty --report junit --output 'test_output/report.junit' - store_artifacts: # This will by default store an html and junit file as artifacts (See "Artifacts" tab in CircleCI report) - path: *test_output_folder # test_output is the default temporary folder for fastlane scan output - destination: *test_output_folder # This will create a sub structure in the artifacts section in CircleCI + path: test_output # test_output is the default temporary folder for test output + destination: test_output # This will create a sub structure in the artifacts section in CircleCI - store_test_results: # This will store the test results so you can then see them in the "Test Summary" tab in CircleCI report - path: *test_output_folder + path: test_output test_workspace_and_store_results: description: "Builds and tests a workspace and then stores the results of the tests as artifacts and test results report" @@ -88,23 +97,27 @@ commands: type: string path: type: string - test_output_folder: + simulator: type: string + default: *test_device + os_version: + type: string + default: *test_device_os steps: - run: - command: | + name: Run examples + command: | cd <> - fastlane scan - environment: - SCAN_WORKSPACE: <> - SCAN_SCHEME: <> - SCAN_DEVICE: *test_device - SCAN_CLEAN: *clean_before_build + xcodebuild -workspace <> \ + -scheme <> \ + -destination "platform=iOS Simulator,OS=<>,name=<>" \ + build test \ + | xcpretty --report junit --output 'test_output/report.junit' - store_artifacts: # This will by default store an html and junit file as artifacts (See "Artifacts" tab in CircleCI report) - path: <>/<> # test_output is the default temporary folder for fastlane scan output - destination: <> # This will create a sub structure in the artifacts section in CircleCI + path: <>/test_output # test_output is the default temporary folder for test output + destination: test_output # This will create a sub structure in the artifacts section in CircleCI - store_test_results: # This will store the test results so you can then see them in the "Test Summary" tab in CircleCI report - path: <>/<> + path: <>/test_output jobs: swiftlint: @@ -136,7 +149,9 @@ jobs: macos: xcode: "13.0.0" steps: - - test_main_project + - test_main_project: + simulator: "iPhone 13" + os_version: "15.0" test-xcode14-ios16: <<: *default_executor diff --git a/CHANGELOG.md b/CHANGELOG.md index 43fbf84..5e33231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 1.11.0 +- Add Compose and SwiftUI bridging functions + # 1.10.2 - Specify type of the library for `spm` builds as `dynamic` diff --git a/Disposable+CombineTests.swift b/Disposable+CombineTests.swift new file mode 100644 index 0000000..88fc00f --- /dev/null +++ b/Disposable+CombineTests.swift @@ -0,0 +1,114 @@ +// +// Disposable+CombineTests.swift +// Flow +// +// Created by Carl Ekman on 2023-02-09. +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import XCTest +#if DEBUG +@testable import Flow +#else +import Flow +#endif +import Foundation +#if canImport(Combine) +import Combine + +@available(iOS 13.0, macOS 10.15, *) +final class Disposable_CombineTests: XCTestCase { + + var bag = CancelBag() + + override func tearDownWithError() throws { + bag.cancel() + + try super.tearDownWithError() + } + + func testCancellingDisposable() { + let disposed = expectation(description: "Disposed") + + let disposer = Disposer { disposed.fulfill() } + disposer.asAnyCancellable.cancel() + + wait(for: [disposed], timeout: 1) + } + + func testCancelBag() { + var bag = CancelBag() + + let cancelled1 = expectation(description: "Cancelled 1") + let cancelled2 = expectation(description: "Cancelled 2") + let cancelled3 = expectation(description: "Cancelled 3") + + bag += { cancelled1.fulfill() } + bag += { cancelled2.fulfill() } + bag += { cancelled3.fulfill() } + + bag.cancel() + XCTAssertFalse(bag.isEmpty) + + wait(for: [cancelled1, cancelled2, cancelled3], timeout: 1) + XCTAssertFalse(bag.isEmpty) + + bag.empty() + XCTAssertTrue(bag.isEmpty) + } + + func testCancellingDisposeBag() { + let bag = DisposeBag() + + let cancelled1 = expectation(description: "Cancelled 1") + let cancelled2 = expectation(description: "Cancelled 2") + let cancelled3 = expectation(description: "Cancelled 3") + + bag += { cancelled1.fulfill() } + bag += { cancelled2.fulfill() } + bag += { cancelled3.fulfill() } + + bag.asAnyCancellable.cancel() + + wait(for: [cancelled1, cancelled2, cancelled3], timeout: 1) + } + + func testDisposeBagToCancelBag() { + let disposeBag = DisposeBag() + + let disposed = expectation(description: "Disposed") + + disposeBag += { disposed.fulfill() } + + var cancelBag = CancelBag(disposable: disposeBag) + cancelBag.empty() + + wait(for: [disposed], timeout: 1) + } + + func testCancelPublisherSink() { + let callbacker = Callbacker>() + + let signal = FiniteSignal(callbacker: callbacker) + let publisher = signal.asAnyPublisher + + let cancelled = expectation(description: "Cancelled") + + bag += { cancelled.fulfill() } + + publisher.sink { _ in + XCTFail("Did not expect completion") + } receiveValue: { _ in + XCTFail("Did not expect value") + }.store(in: &bag) + + bag.cancel() + callbacker.callAll(with: .value(1)) + callbacker.callAll(with: .end(TestError.fatal)) + + wait(for: [cancelled], timeout: 1) + } + +} + +#endif diff --git a/Flow.xcodeproj/project.pbxproj b/Flow.xcodeproj/project.pbxproj index 2ac8406..010d947 100644 --- a/Flow.xcodeproj/project.pbxproj +++ b/Flow.xcodeproj/project.pbxproj @@ -11,10 +11,19 @@ 215DEF371DEC368700CEB724 /* RecursiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 215DEF351DEC367E00CEB724 /* RecursiveTests.swift */; }; 21E1D41C1D9502A300A91CA0 /* Future+Signal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21E1D41B1D9502A300A91CA0 /* Future+Signal.swift */; }; 5B46DE3D22E9CC5E00E0A4D9 /* PrefetchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B46DE3B22E9CBFA00E0A4D9 /* PrefetchTests.swift */; }; + 5BB2E8AD2994238C0095F9E1 /* Signal+CombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2E8AC2994238C0095F9E1 /* Signal+CombineTests.swift */; }; + 5BB2E8C32994E5360095F9E1 /* Signal+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2E8C02994E5360095F9E1 /* Signal+SwiftUI.swift */; }; + 5BB2E8C42994E5360095F9E1 /* Future+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2E8C22994E5360095F9E1 /* Future+Combine.swift */; }; + 5BB2E8C52994E5360095F9E1 /* Signal+Combine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2E8C12994E5360095F9E1 /* Signal+Combine.swift */; }; + 5BB2E8CC2994F0160095F9E1 /* Future+CombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BB2E8CA2994F0080095F9E1 /* Future+CombineTests.swift */; }; 5BE9055B3538F1DDAB424AD2 /* FutureAdditionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE90C011B621BC2F0B9C1B8 /* FutureAdditionsTests.swift */; }; 7484FA6B212D9E930076FD3E /* Signal+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7484FA6A212D9E930076FD3E /* Signal+Debug.swift */; }; 792AC15B227C8A6800F8BBAD /* SignalProviderTests+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 792AC15A227C8A6800F8BBAD /* SignalProviderTests+Internal.swift */; }; 8E890FC106FB7A89BD1727CC /* EitherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E890194B06CBB3311E44757 /* EitherTests.swift */; }; + B6B4E3572994FB6500D7FFF2 /* Disposable+Cancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4E3562994FB6500D7FFF2 /* Disposable+Cancellable.swift */; }; + B6B4E35A299506BF00D7FFF2 /* Disposable+CombineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4E3582995028900D7FFF2 /* Disposable+CombineTests.swift */; }; + B6B4E35C29952CCC00D7FFF2 /* CancelBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4E35B29952CCC00D7FFF2 /* CancelBag.swift */; }; + B6B4E36029954B4600D7FFF2 /* Publisher+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B4E35F29954B4600D7FFF2 /* Publisher+Utilities.swift */; }; DA6D58EF230E925700564CC1 /* MemoryUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6D58EB230E918800564CC1 /* MemoryUtilsTests.swift */; }; F610ABAE1D91743500A161AB /* Future+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F610ABA71D91743500A161AB /* Future+Additions.swift */; }; F610ABB01D91743500A161AB /* FutureQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = F610ABA91D91743500A161AB /* FutureQueue.swift */; }; @@ -86,10 +95,19 @@ 215DEF351DEC367E00CEB724 /* RecursiveTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = RecursiveTests.swift; path = FlowTests/RecursiveTests.swift; sourceTree = SOURCE_ROOT; }; 21E1D41B1D9502A300A91CA0 /* Future+Signal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Future+Signal.swift"; path = "Flow/Future+Signal.swift"; sourceTree = SOURCE_ROOT; }; 5B46DE3B22E9CBFA00E0A4D9 /* PrefetchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = PrefetchTests.swift; path = FlowTests/PrefetchTests.swift; sourceTree = ""; }; + 5BB2E8AC2994238C0095F9E1 /* Signal+CombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Signal+CombineTests.swift"; path = "FlowTests/Signal+CombineTests.swift"; sourceTree = ""; }; + 5BB2E8C02994E5360095F9E1 /* Signal+SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Signal+SwiftUI.swift"; sourceTree = ""; }; + 5BB2E8C12994E5360095F9E1 /* Signal+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Signal+Combine.swift"; sourceTree = ""; }; + 5BB2E8C22994E5360095F9E1 /* Future+Combine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Future+Combine.swift"; sourceTree = ""; }; + 5BB2E8CA2994F0080095F9E1 /* Future+CombineTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Future+CombineTests.swift"; path = "FlowTests/Future+CombineTests.swift"; sourceTree = ""; }; 5BE90C011B621BC2F0B9C1B8 /* FutureAdditionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = FutureAdditionsTests.swift; path = FlowTests/FutureAdditionsTests.swift; sourceTree = ""; }; 7484FA6A212D9E930076FD3E /* Signal+Debug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "Signal+Debug.swift"; path = "Flow/Signal+Debug.swift"; sourceTree = ""; }; 792AC15A227C8A6800F8BBAD /* SignalProviderTests+Internal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "SignalProviderTests+Internal.swift"; path = "FlowTests/SignalProviderTests+Internal.swift"; sourceTree = ""; }; 8E890194B06CBB3311E44757 /* EitherTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = EitherTests.swift; path = FlowTests/EitherTests.swift; sourceTree = ""; }; + B6B4E3562994FB6500D7FFF2 /* Disposable+Cancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Disposable+Cancellable.swift"; sourceTree = ""; }; + B6B4E3582995028900D7FFF2 /* Disposable+CombineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Disposable+CombineTests.swift"; sourceTree = ""; }; + B6B4E35B29952CCC00D7FFF2 /* CancelBag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelBag.swift; sourceTree = ""; }; + B6B4E35F29954B4600D7FFF2 /* Publisher+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Utilities.swift"; sourceTree = ""; }; DA6D58EB230E918800564CC1 /* MemoryUtilsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = MemoryUtilsTests.swift; path = FlowTests/MemoryUtilsTests.swift; sourceTree = ""; }; F610ABA61D91743500A161AB /* Future.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Future.swift; path = Flow/Future.swift; sourceTree = SOURCE_ROOT; }; F610ABA71D91743500A161AB /* Future+Additions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = "Future+Additions.swift"; path = "Flow/Future+Additions.swift"; sourceTree = SOURCE_ROOT; }; @@ -178,6 +196,20 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 5BB2E8C72994E54C0095F9E1 /* Bridges */ = { + isa = PBXGroup; + children = ( + B6B4E35B29952CCC00D7FFF2 /* CancelBag.swift */, + 5BB2E8C22994E5360095F9E1 /* Future+Combine.swift */, + 5BB2E8C12994E5360095F9E1 /* Signal+Combine.swift */, + 5BB2E8C02994E5360095F9E1 /* Signal+SwiftUI.swift */, + B6B4E3562994FB6500D7FFF2 /* Disposable+Cancellable.swift */, + B6B4E35F29954B4600D7FFF2 /* Publisher+Utilities.swift */, + ); + name = Bridges; + path = Flow/Bridges; + sourceTree = ""; + }; F6442D7220C6C9A400319327 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -254,6 +286,7 @@ F688B944205FB78B00BA5A70 /* FlowFramework.podspec */, F688B943205FB78A00BA5A70 /* Package.swift */, F6EDC6DE2066BD39007AC39B /* Documentation */, + 5BB2E8C72994E54C0095F9E1 /* Bridges */, F6A8803C1D9181EC00CA257F /* Signal */, F6D80B5B1BBBB2ED008F8574 /* Future */, F66F8528205AAE5700347601 /* Scheduler */, @@ -315,6 +348,9 @@ 5BE90C011B621BC2F0B9C1B8 /* FutureAdditionsTests.swift */, 5B46DE3B22E9CBFA00E0A4D9 /* PrefetchTests.swift */, DA6D58EB230E918800564CC1 /* MemoryUtilsTests.swift */, + 5BB2E8AC2994238C0095F9E1 /* Signal+CombineTests.swift */, + 5BB2E8CA2994F0080095F9E1 /* Future+CombineTests.swift */, + B6B4E3582995028900D7FFF2 /* Disposable+CombineTests.swift */, ); name = Tests; sourceTree = ""; @@ -476,6 +512,7 @@ F6B6A65F2056AEA400B9FC9D /* ReadSignal.swift in Sources */, F6FF03E71D926AC300B93771 /* Utilities.swift in Sources */, F66C47A720077B2500333410 /* Signal+Combiners.swift in Sources */, + 5BB2E8C42994E5360095F9E1 /* Future+Combine.swift in Sources */, F662C0A71FDFDEB300E5F869 /* Signal+Scheduling.swift in Sources */, F6FF03E41D926AC300B93771 /* CoreSignal.swift in Sources */, F67C4798206CDDCC00BEBDFD /* FiniteSignal.swift in Sources */, @@ -486,11 +523,15 @@ F6FF03E21D926AC300B93771 /* Signal+Transforms.swift in Sources */, F6FF03E31D926AC300B93771 /* Signal+KeyValueObserving.swift in Sources */, F68EF3551FD58FD20001129C /* UIView+Signal.swift in Sources */, + B6B4E36029954B4600D7FFF2 /* Publisher+Utilities.swift in Sources */, F6B6A6632056AF4300B9FC9D /* ReadWriteSignal.swift in Sources */, F68EF3531FD58FC70001129C /* Event.swift in Sources */, + B6B4E35C29952CCC00D7FFF2 /* CancelBag.swift in Sources */, F6462A421EFAAD06007E2198 /* Scheduler.swift in Sources */, 7484FA6B212D9E930076FD3E /* Signal+Debug.swift in Sources */, + 5BB2E8C32994E5360095F9E1 /* Signal+SwiftUI.swift in Sources */, F6B6A6652056B2CA00B9FC9D /* EventType.swift in Sources */, + B6B4E3572994FB6500D7FFF2 /* Disposable+Cancellable.swift in Sources */, F610ABB21D91743500A161AB /* Result.swift in Sources */, F66C47A920077BC700333410 /* Signal+Listeners.swift in Sources */, F64E975F201888EB00865380 /* Future+Combiners.swift in Sources */, @@ -498,6 +539,7 @@ F66835CF2091B887002D2676 /* UIView+EditingMenu.swift in Sources */, F6FF03E51D926AC300B93771 /* TargetActionable.swift in Sources */, F699C483205C1A5C001378C0 /* Signal+Utilities.swift in Sources */, + 5BB2E8C52994E5360095F9E1 /* Signal+Combine.swift in Sources */, F667FCD8200604570014DA7D /* Enablable.swift in Sources */, F681B3481DB6566E00E44ABD /* Either.swift in Sources */, ); @@ -507,7 +549,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5BB2E8AD2994238C0095F9E1 /* Signal+CombineTests.swift in Sources */, F610ABBC1D91747000A161AB /* FutureBasicTests.swift in Sources */, + 5BB2E8CC2994F0160095F9E1 /* Future+CombineTests.swift in Sources */, F6A880921D9182B900CA257F /* SignalProviderTests.swift in Sources */, F610ABBF1D91747000A161AB /* FutureSchedulingTests.swift in Sources */, F6AC447F1FDE6F240090FBE7 /* SignalConcurrenceyTests.swift in Sources */, @@ -523,6 +567,7 @@ 792AC15B227C8A6800F8BBAD /* SignalProviderTests+Internal.swift in Sources */, F6C0FED2202B44360076B877 /* DelegateTests.swift in Sources */, F6F679A320A966D1004C7AA7 /* EventListenerTests.swift in Sources */, + B6B4E35A299506BF00D7FFF2 /* Disposable+CombineTests.swift in Sources */, 5B46DE3D22E9CC5E00E0A4D9 /* PrefetchTests.swift in Sources */, F610ABBD1D91747000A161AB /* FutureQueueTests.swift in Sources */, DA6D58EF230E925700564CC1 /* MemoryUtilsTests.swift in Sources */, diff --git a/Flow/Bridges/CancelBag.swift b/Flow/Bridges/CancelBag.swift new file mode 100644 index 0000000..d113aea --- /dev/null +++ b/Flow/Bridges/CancelBag.swift @@ -0,0 +1,63 @@ +// +// CancelBag.swift +// Flow +// +// Created by Carl Ekman on 2023-02-09. +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import Foundation +#if canImport(Combine) +import Combine + +/// A type alias for `Set` meant to bridge some of the patterns of `DisposeBag` +/// with modern conventions, like `store(in set: inout Set)`. +@available(iOS 13.0, macOS 10.15, *) +public typealias CancelBag = Set + +@available(iOS 13.0, macOS 10.15, *) +extension CancelBag: Cancellable { + /// Cancel all elements in the set. + public func cancel() { + forEach { $0.cancel() } + } + + /// Cancel all elements and then empty the set. + public mutating func empty() { + cancel() + removeAll() + } + + /// Create a new, empty set, which is itself a part of self. + /// Corresponds to `innerBag()` for `DisposeBag`. + public mutating func subset() -> CancelBag { + let bag = CancelBag() + self.insert(AnyCancellable(bag)) + return bag + } +} + +@available(iOS 13.0, macOS 10.15, *) +extension CancelBag { + public init(disposable: Disposable) { + self.init([disposable.asAnyCancellable]) + } + + public var asAnyCancellable: AnyCancellable { + AnyCancellable(self) + } +} + +@available(iOS 13.0, macOS 10.15, *) +public func += (cancelBag: inout CancelBag, cancellable: AnyCancellable?) { + if let cancellable = cancellable { + cancelBag.insert(cancellable) + } +} + +@available(iOS 13.0, macOS 10.15, *) +public func += (cancelBag: inout CancelBag, cancellation: @escaping () -> Void) { + cancelBag.insert(AnyCancellable(cancellation)) +} + +#endif diff --git a/Flow/Bridges/Disposable+Cancellable.swift b/Flow/Bridges/Disposable+Cancellable.swift new file mode 100644 index 0000000..abb7b5f --- /dev/null +++ b/Flow/Bridges/Disposable+Cancellable.swift @@ -0,0 +1,27 @@ +// +// Disposable+Cancellable.swift +// Flow +// +// Created by Carl Ekman on 2023-02-09. +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import Foundation +#if canImport(Combine) +import Combine + +@available(iOS 13.0, macOS 10.15, *) +extension Disposable { + public var asAnyCancellable: AnyCancellable { + AnyCancellable { self.dispose() } + } +} + +@available(iOS 13.0, macOS 10.15, *) +extension Future { + public var cancellable: AnyCancellable { + AnyCancellable { self.disposable.dispose() } + } +} + +#endif diff --git a/Flow/Bridges/Future+Combine.swift b/Flow/Bridges/Future+Combine.swift new file mode 100644 index 0000000..a27fedb --- /dev/null +++ b/Flow/Bridges/Future+Combine.swift @@ -0,0 +1,20 @@ +// +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import Foundation +#if canImport(Combine) +import Combine + +@available(iOS 13.0, macOS 10.15, *) +extension Flow.Future { + /// Convert a `Flow.Future` to a `Combine.Future` intended to be + /// used to bridge between the `Flow` and `Combine` world + public var toCombineFuture: Combine.Future { + Combine.Future { promise in + self.onResult { promise($0) } + } + } +} + +#endif diff --git a/Flow/Bridges/Publisher+Utilities.swift b/Flow/Bridges/Publisher+Utilities.swift new file mode 100644 index 0000000..f1589f2 --- /dev/null +++ b/Flow/Bridges/Publisher+Utilities.swift @@ -0,0 +1,59 @@ +// +// Callbacker+Combine.swift +// Flow +// +// Created by Carl Ekman on 2023-02-09. +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import Foundation +#if canImport(Combine) +import Combine + +@available(iOS 13.0, macOS 10.15, *) +public extension Publisher { + /// Performs just link `sink(receiveValue:)`, but the cancellable produced from each received value + /// will be automatically cancelled once a new value is published. Completion will cancel the last cancellable as well. + /// + /// - Intended to be used similarly to `onValueDisposePrevious(_:on:)`. + func autosink( + receiveCompletion: @escaping ((Subscribers.Completion) -> Void), + receiveValue: @escaping ((Self.Output) -> AnyCancellable) + ) -> AnyCancellable { + var bag = CancelBag() + var subBag = bag.subset() + + bag += sink(receiveCompletion: { completion in + subBag.cancel() + receiveCompletion(completion) + }, receiveValue: { value in + subBag.cancel() + subBag += receiveValue(value) + }) + + return bag.asAnyCancellable + } +} + +@available(iOS 13.0, macOS 10.15, *) +public extension Publisher where Self.Failure == Never { + /// Performs just link `sink(receiveValue:)`, but the cancellable produced from each received value + /// will be automatically cancelled once a new value is published, for publishers that never fail. + /// + /// - Intended to be used similarly to `onValueDisposePrevious(_:on:)`. + func autosink( + receiveValue: @escaping ((Self.Output) -> AnyCancellable) + ) -> AnyCancellable { + var bag = CancelBag() + var subBag = bag.subset() + + bag += sink { value in + subBag.cancel() + subBag += receiveValue(value) + } + + return bag.asAnyCancellable + } +} + +#endif diff --git a/Flow/Bridges/Signal+Combine.swift b/Flow/Bridges/Signal+Combine.swift new file mode 100644 index 0000000..19dca37 --- /dev/null +++ b/Flow/Bridges/Signal+Combine.swift @@ -0,0 +1,91 @@ +// +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import Foundation +#if canImport(Combine) +import Combine + +extension CoreSignal { + @available(iOS 13.0, macOS 10.15, *) + final class SignalPublisher: Publisher, Cancellable { + typealias Output = Value + typealias Failure = Error + + internal var signal: CoreSignal + internal var bag: CancelBag + + init(signal: CoreSignal) { + self.signal = signal + self.bag = [] + } + + func receive( + subscriber: S + ) where S : Subscriber, Failure == S.Failure, Value == S.Input { + // Creating our custom subscription instance: + let subscription = EventSubscription() + subscription.target = subscriber + + // Attaching our subscription to the subscriber: + subscriber.receive(subscription: subscription) + + // Collect cancellables when attaching to signal + bag += signal + .onValue { subscription.trigger(for: $0) } + .asAnyCancellable + + if let finiteVersion = signal as? FiniteSignal { + bag += finiteVersion.onEvent { event in + if case let .end(error) = event { + if let error = error { + subscription.end(with: error) + } else { + subscription.end() + } + } + }.asAnyCancellable + } + } + + func cancel() { + bag.cancel() + } + + deinit { + cancel() + } + } + + @available(iOS 13.0, macOS 10.15, *) + final class EventSubscription: Subscription + where Target.Input == Value { + + var target: Target? + + func request(_ demand: Subscribers.Demand) {} + + func cancel() { + target = nil + } + + func end(with error: Target.Failure? = nil) { + if let error = error { + _ = target?.receive(completion: .failure(error)) + } else { + _ = target?.receive(completion: .finished) + } + } + + func trigger(for value: Value) { + _ = target?.receive(value) + } + } + + @available(iOS 13.0, macOS 10.15, *) + public var asAnyPublisher: AnyPublisher { + SignalPublisher(signal: self).eraseToAnyPublisher() + } +} + +#endif diff --git a/Flow/Bridges/Signal+SwiftUI.swift b/Flow/Bridges/Signal+SwiftUI.swift new file mode 100644 index 0000000..7198567 --- /dev/null +++ b/Flow/Bridges/Signal+SwiftUI.swift @@ -0,0 +1,38 @@ +// +// Signal+SwiftUI.swift +// Flow +// +// Created by Martin Andonoski on 2023-02-09. +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import Foundation +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 13.0, macOS 10.15, *) +extension CoreSignal where Kind == ReadWrite { + /// Convert a `ReadWriteSignal` to a `Binding` intended to be used for SwiftUI view models + /// to bridge between the `Flow` and `SwiftUI` world + public var asBinding: Binding { + Binding( + get: { self.value }, + set: { self.value = $0 } + ) + } + +} + +@available(iOS 13.0, macOS 10.15, *) +extension CoreSignal where Kind == Read { + /// Convert a `ReadSignal` to a `Binding` intended to be used for SwiftUI view models + /// to bridge between the `Flow` and `SwiftUI` world + public var asBinding: Binding { + Binding( + get: { self.value }, + set: { _ in } + ) + } + +} +#endif diff --git a/FlowTests/Future+CombineTests.swift b/FlowTests/Future+CombineTests.swift new file mode 100644 index 0000000..b1ae9c2 --- /dev/null +++ b/FlowTests/Future+CombineTests.swift @@ -0,0 +1,70 @@ +// +// Future+CombineTests.swift +// FlowTests +// +// Created by Martin Andonoski on 2023-02-09. +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import XCTest +@testable import Flow +#if canImport(Combine) +import Combine + +@available(iOS 13.0, macOS 10.15, *) +final class Future_CombineTests: XCTestCase { + + var bag = CancelBag() + + override func tearDownWithError() throws { + bag.empty() + + try super.tearDownWithError() + } + + func testGetValue() { + let callbacker = Callbacker>() + let flowFuture = Flow.Future(callbacker: callbacker) + + let combineFuture = flowFuture.toCombineFuture + let expectation = self.expectation(description: "Result expected") + + bag += combineFuture.sink { completion in + if case .failure = completion { + XCTFail("Expected to succeed") + } + } receiveValue: { value in + XCTAssertEqual(value, 1) + expectation.fulfill() + } + + callbacker.callAll(with: .success(1)) + wait(for: [expectation], timeout: 1) + } + + func testErrorOut() { + let callbacker = Callbacker>() + let flowFuture = Flow.Future(callbacker: callbacker) + + let combineFuture = flowFuture.toCombineFuture + let expectation = self.expectation(description: "Failiure expected") + + bag += combineFuture.sink { completion in + switch completion { + case .failure(let error): + XCTAssertEqual(error as! TestError, .fatal) + expectation.fulfill() + case .finished: + XCTFail("Expected to fail") + } + } receiveValue: { value in + XCTFail("Expected to fail") + } + + callbacker.callAll(with: .failure(TestError.fatal)) + wait(for: [expectation], timeout: 1) + } + +} + +#endif diff --git a/FlowTests/Signal+CombineTests.swift b/FlowTests/Signal+CombineTests.swift new file mode 100644 index 0000000..cbba680 --- /dev/null +++ b/FlowTests/Signal+CombineTests.swift @@ -0,0 +1,127 @@ +// +// ReadSignal+CombineTests.swift +// FlowTests +// +// Created by Martin Andonoski on 2023-02-08. +// Copyright © 2023 PayPal Inc. All rights reserved. +// + +import XCTest +#if DEBUG +@testable import Flow +#else +import Flow +#endif +import Foundation +#if canImport(Combine) +import Combine + +@available(iOS 13.0, macOS 10.15, *) +final class Signal_CombineTests: XCTestCase { + + var bag = CancelBag() + + override func tearDownWithError() throws { + bag.empty() + + try super.tearDownWithError() + } + + func testValueReceived() { + let signal = ReadWriteSignal("1") + let publisher = signal.asAnyPublisher + + let valueExpectation = self.expectation(description: "value should fire") + + bag += publisher.sink { completion in + XCTFail("Should not complete") + } receiveValue: { value in + XCTAssertEqual(value, "2") + valueExpectation.fulfill() + } + + signal.value = "2" + + wait(for: [valueExpectation], timeout: 1) + } + + func testEndReceived() { + let callbacker = Callbacker>() + let signal = FiniteSignal(callbacker: callbacker) + let publisher = signal.asAnyPublisher + + let endExpectation = self.expectation(description: "signal should end") + + bag += publisher.sink { completion in + switch completion { + case .finished: + endExpectation.fulfill() + case .failure: + XCTFail("Should not fail") + } + } receiveValue: { value in + XCTFail("Cancelable should have ended") + } + + callbacker.callAll(with: .end) + callbacker.callAll(with: .value(1)) + + wait(for: [endExpectation], timeout: 1) + } + + func testErrorReceived() { + let callbacker = Callbacker>() + let signal = FiniteSignal(callbacker: callbacker) + let publisher = signal.asAnyPublisher + + let endExpectation = self.expectation(description: "signal should end with error") + bag += publisher.sink { completion in + switch completion { + case .finished: + XCTFail("Should fail") + case .failure(let error): + XCTAssertEqual(error as! TestError, .fatal) + endExpectation.fulfill() + } + } receiveValue: { value in + XCTFail("Cancellable should have ended") + } + + callbacker.callAll(with: .end(TestError.fatal)) + callbacker.callAll(with: .value(1)) + + wait(for: [endExpectation], timeout: 1) + } + + func testAutosink() { + let completed = expectation(description: "Completed") + let valueSunk = expectation(description: "Value sunk") + valueSunk.expectedFulfillmentCount = 3 + + let autocancel1 = expectation(description: "1") + let autocancel2 = expectation(description: "2") + let autocancel3 = expectation(description: "3") + + bag += (1...3).publisher.autosink { completion in + completed.fulfill() + } receiveValue: { value in + valueSunk.fulfill() + + var subBag = self.bag.subset() + + switch value { + case 1: subBag += { autocancel1.fulfill() } + case 2: subBag += { autocancel2.fulfill() } + case 3: subBag += { autocancel3.fulfill() } + default: XCTFail("Unexpected value out of range") + } + + return subBag.asAnyCancellable + } + + wait(for: [valueSunk, autocancel1, autocancel2, autocancel3, completed], timeout: 1) + } + +} + +#endif