From 0e075cc1e86fd2176a7f443a91a51783a3ce5ba2 Mon Sep 17 00:00:00 2001 From: Leif Date: Tue, 6 Aug 2024 19:49:59 -0600 Subject: [PATCH] init --- .github/workflows/docc.yml | 36 ++ .github/workflows/macOS.yml | 18 + .github/workflows/ubuntu.yml | 18 + .github/workflows/windows.yml | 20 ++ .gitignore | 4 +- .swiftpm/Later.xctestplan | 25 ++ LICENSE | 21 ++ Package.swift | 27 +- README.md | 222 ++++++++++++ Sources/Later/Deferred/Deferred+Builder.swift | 40 +++ Sources/Later/Deferred/Deferred.swift | 65 ++++ Sources/Later/Emitter/Emitter.swift | 26 ++ Sources/Later/Extensions/Task+Schedule.swift | 34 ++ Sources/Later/Future/Future+Builder.swift | 51 +++ Sources/Later/Future/Future.swift | 40 +++ Sources/Later/Later.swift | 2 - Sources/Later/Publisher/AnySubscriber.swift | 36 ++ .../Later/Publisher/Publisher+Deferred.swift | 23 ++ .../Later/Publisher/Publisher+Future.swift | 19 + .../Later/Publisher/Publisher+Stream.swift | 26 ++ Sources/Later/Publisher/Publisher.swift | 73 ++++ Sources/Later/Publisher/Subscribing.swift | 8 + .../Later/SendableValue/SendableValue.swift | 34 ++ Sources/Later/Stream/Stream+Builder.swift | 54 +++ Sources/Later/Stream/Stream.swift | 148 ++++++++ .../Deferred/DeferredBuilderTests.swift | 54 +++ Tests/LaterTests/Deferred/DeferredTests.swift | 72 ++++ .../Future/FutureBuilderTests.swift | 77 +++++ Tests/LaterTests/Future/FutureTests.swift | 73 ++++ Tests/LaterTests/Helpers/Testing.swift | 7 + Tests/LaterTests/LaterTests.swift | 6 - .../LaterTests/Publisher/PublisherTests.swift | 324 ++++++++++++++++++ .../SendableValue/SendableValueTests.swift | 169 +++++++++ .../Stream/StreamBuilderTests.swift | 164 +++++++++ Tests/LaterTests/Stream/StreamTests.swift | 123 +++++++ 35 files changed, 2121 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/docc.yml create mode 100644 .github/workflows/macOS.yml create mode 100644 .github/workflows/ubuntu.yml create mode 100644 .github/workflows/windows.yml create mode 100644 .swiftpm/Later.xctestplan create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Sources/Later/Deferred/Deferred+Builder.swift create mode 100644 Sources/Later/Deferred/Deferred.swift create mode 100644 Sources/Later/Emitter/Emitter.swift create mode 100644 Sources/Later/Extensions/Task+Schedule.swift create mode 100644 Sources/Later/Future/Future+Builder.swift create mode 100644 Sources/Later/Future/Future.swift delete mode 100644 Sources/Later/Later.swift create mode 100644 Sources/Later/Publisher/AnySubscriber.swift create mode 100644 Sources/Later/Publisher/Publisher+Deferred.swift create mode 100644 Sources/Later/Publisher/Publisher+Future.swift create mode 100644 Sources/Later/Publisher/Publisher+Stream.swift create mode 100644 Sources/Later/Publisher/Publisher.swift create mode 100644 Sources/Later/Publisher/Subscribing.swift create mode 100644 Sources/Later/SendableValue/SendableValue.swift create mode 100644 Sources/Later/Stream/Stream+Builder.swift create mode 100644 Sources/Later/Stream/Stream.swift create mode 100644 Tests/LaterTests/Deferred/DeferredBuilderTests.swift create mode 100644 Tests/LaterTests/Deferred/DeferredTests.swift create mode 100644 Tests/LaterTests/Future/FutureBuilderTests.swift create mode 100644 Tests/LaterTests/Future/FutureTests.swift create mode 100644 Tests/LaterTests/Helpers/Testing.swift delete mode 100644 Tests/LaterTests/LaterTests.swift create mode 100644 Tests/LaterTests/Publisher/PublisherTests.swift create mode 100644 Tests/LaterTests/SendableValue/SendableValueTests.swift create mode 100644 Tests/LaterTests/Stream/StreamBuilderTests.swift create mode 100644 Tests/LaterTests/Stream/StreamTests.swift diff --git a/.github/workflows/docc.yml b/.github/workflows/docc.yml new file mode 100644 index 0000000..9135bdb --- /dev/null +++ b/.github/workflows/docc.yml @@ -0,0 +1,36 @@ +name: docc +on: + push: + branches: ["main"] +permissions: + contents: read + pages: write + id-token: write +concurrency: + group: "pages" + cancel-in-progress: true +jobs: + pages: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: macos-12 + steps: + - name: git checkout + uses: actions/checkout@v3 + - name: docbuild + run: | + xcodebuild docbuild -scheme Later \ + -derivedDataPath /tmp/docbuild \ + -destination 'generic/platform=iOS'; + $(xcrun --find docc) process-archive \ + transform-for-static-hosting /tmp/docbuild/Build/Products/Debug-iphoneos/Later.doccarchive \ + --output-path docs; + echo "" > docs/index.html; + - name: artifacts + uses: actions/upload-pages-artifact@v1 + with: + path: 'docs' + - name: deploy + id: deployment + uses: actions/deploy-pages@v1 \ No newline at end of file diff --git a/.github/workflows/macOS.yml b/.github/workflows/macOS.yml new file mode 100644 index 0000000..0496b97 --- /dev/null +++ b/.github/workflows/macOS.yml @@ -0,0 +1,18 @@ +name: macOS + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v3 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v \ No newline at end of file diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml new file mode 100644 index 0000000..8597996 --- /dev/null +++ b/.github/workflows/ubuntu.yml @@ -0,0 +1,18 @@ +name: Ubuntu + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build + run: swift build -v + - name: Run tests + run: swift test -v \ No newline at end of file diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..4de39f2 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,20 @@ +name: Windows + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + runs-on: windows-latest + steps: + - uses: compnerd/gha-setup-swift@main + with: + branch: swift-5.9-release + tag: 5.9-RELEASE + + - uses: actions/checkout@v2 + - run: swift build + - run: swift test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0023a53..ee98d0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ .DS_Store /.build /Packages +/*.xcodeproj xcuserdata/ DerivedData/ -.swiftpm/configuration/registries.json +.swiftpm/config/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +/.swiftpm \ No newline at end of file diff --git a/.swiftpm/Later.xctestplan b/.swiftpm/Later.xctestplan new file mode 100644 index 0000000..02dfc07 --- /dev/null +++ b/.swiftpm/Later.xctestplan @@ -0,0 +1,25 @@ +{ + "configurations" : [ + { + "id" : "D41E1DE8-9029-4D28-93B9-89342D97A149", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "testExecutionOrdering" : "random" + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:", + "identifier" : "LaterTests", + "name" : "LaterTests" + } + } + ], + "version" : 1 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b528b19 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Zach Eriksen + +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 3727d48..d9e8805 100644 --- a/Package.swift +++ b/Package.swift @@ -1,25 +1,34 @@ -// swift-tools-version: 6.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "Later", - platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)], + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .tvOS(.v13), + .watchOS(.v6), + .macCatalyst(.v13), + .visionOS(.v1) + ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "Later", - targets: ["Later"]), + targets: ["Later"] + ) ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( - name: "Later"), + name: "Later", + swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + .unsafeFlags(["-warnings-as-errors"]) + ] + ), .testTarget( name: "LaterTests", dependencies: ["Later"] - ), + ) ] ) diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc40763 --- /dev/null +++ b/README.md @@ -0,0 +1,222 @@ +# Later + +`Later` is a lightweight Swift library designed to simplify asynchronous programming by providing foundational building blocks such as `Future`, `Deferred`, `Stream`, and `Publisher`. These components enable you to manage and coordinate asynchronous tasks, making it easier to write clean and maintainable code. + +## Features + +- **Future**: Represents a value that will be available asynchronously in the future. +- **Deferred**: Represents a value that will be computed and available asynchronously when explicitly started. +- **Stream**: Represents an asynchronous sequence of values emitted over time. +- **Publisher**: Allows objects to subscribe to changes in state or data and notifies subscribers when the state or data changes. +- **Subscribing**: A protocol for objects that want to observe changes in state or data. + +## Installation + +### Swift Package Manager + +Add `Later` to your `Package.swift` file: + +```swift +dependencies: [ + .package(url: "https://github.com/0xLeif/Later.git", from: "1.0.0") +] +``` + +And add it to your target’s dependencies: + +```swift +.target( + name: "YourTarget", + dependencies: ["Later"] +) +``` + +## Usage + +### Future + +A `Future` represents a value that will be available asynchronously in the future. + +```swift +import Later + +@Sendable func asyncTask() async throws -> String { + return "Hello" +} + +let future = Future { + do { + let result = try await asyncTask() + return result + } catch { + throw error + } +} + +do { + let result = try await future.value + print(result) // Prints "Hello" +} catch { + print("Error: \(error)") +} +``` + +### Deferred + +A `Deferred` represents a value that will be computed and available asynchronously when explicitly started. + +```swift +import Later + +@Sendable func asyncDeferredTask() async throws -> String { + return "Deferred Hello" +} + +var deferred = Deferred { + do { + let result = try await asyncDeferredTask() + return result + } catch { + throw error + } +} + +deferred.start() + +do { + let result = try await deferred.value + print(result) // Prints "Deferred Hello" +} catch { + print("Error: \(error)") +} +``` + +### Stream + +A `Stream` represents an asynchronous sequence of values emitted over time. + +```swift +import Later + +@Sendable func asyncStreamTask1() async throws -> String { + return "First value" +} + +@Sendable func asyncStreamTask2() async throws -> String { + return "Second value" +} + +let stream = Stream { emitter in + do { + let value1 = try await asyncStreamTask1() + emitter.emit(value: value1) + let value2 = try await asyncStreamTask2() + emitter.emit(value: value2) + } catch { + // Handle error if necessary + } +} + +Task { + for try await value in stream { + print(value) // Prints "First value" and then "Second value" + } +} +``` + +### Publisher and Subscribing TODO + +A `Publisher` allows objects to subscribe to changes in data and notifies subscribers when the data changes. + +```swift +import Later + +class MySubscriber: Subscribing { + typealias Value = String + + func didUpdate(newValue: String?) { + print("New value: \(String(describing: newValue))") + } +} + +let subscriber = MySubscriber() +let publisher = Publisher(initialValue: "Initial value", subscribers: [subscriber]) + +// Using Future with Publisher +let future = await publisher.future( + didSucceed: nil, + didFail: nil, + task: { + return "Future value" + } +) + +do { + let value = try await future.value + print("Future completed with value: \(value)") +} catch { + print("Future failed with error: \(error)") +} + +// Using Deferred with Publisher +var deferred = await publisher.deferred( + didSucceed: nil, + didFail: nil, + task: { + return "Deferred value" + } +) + +deferred.start() + +do { + let value = try await deferred.value + print("Deferred completed with value: \(value)") +} catch { + print("Deferred failed with error: \(error)") +} + +// Using Stream with Publisher +let stream = await publisher.stream( + didSucceed: nil, + didFail: nil, + task: { emitter in + emitter.emit(value: "Stream value 1") + emitter.emit(value: "Stream value 2") + } +) + +var streamValues: [String] = [] +for try await value in stream { + streamValues.append(value) + print("Stream emitted value: \(value)") +} + +print("Stream completed with values: \(streamValues)") +``` + +### Schedule Task + +You can schedule tasks to be executed after a specified duration using the `Task` extension. + +Availability: `@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)` + +```swift +import Later + +func asyncScheduledTask() async throws { + print("Task executed") +} + +do { + try await Task.schedule(for: .seconds(5)) { + try await asyncScheduledTask() + } +} catch { + print("Failed to execute task: \(error)") +} +``` + +## Contributing + +Contributions are welcome! Please feel free to submit a pull request or open an issue if you have any suggestions or bug reports. Please create an issue before submitting any pull request to make sure the work isn’t already being worked on by someone else. diff --git a/Sources/Later/Deferred/Deferred+Builder.swift b/Sources/Later/Deferred/Deferred+Builder.swift new file mode 100644 index 0000000..7a49194 --- /dev/null +++ b/Sources/Later/Deferred/Deferred+Builder.swift @@ -0,0 +1,40 @@ +extension Deferred { + /// A builder class to construct a `Deferred` instance. + public class Builder { + private var onSuccess: (@Sendable (Value) -> Void)? + private var onFailure: (@Sendable (Error) -> Void)? + private let task: @Sendable () async throws -> Value + + /// Initializes the builder with an asynchronous task. + /// - Parameter task: The asynchronous task to be performed. + public init(task: @escaping @Sendable () async throws -> Value) { + self.task = task + } + + /// Sets the success closure. + /// - Parameter closure: A closure that is called when the task succeeds. + /// - Returns: The builder instance. + public func onSuccess(_ closure: @escaping @Sendable (Value) -> Void) -> Builder { + self.onSuccess = closure + return self + } + + /// Sets the failure closure. + /// - Parameter closure: A closure that is called when the task fails. + /// - Returns: The builder instance. + public func onFailure(_ closure: @escaping @Sendable (Error) -> Void) -> Builder { + self.onFailure = closure + return self + } + + /// Builds and returns a `Deferred` instance. + /// - Returns: A configured `Deferred` instance. + public func build() -> Deferred { + Deferred( + didSucceed: onSuccess, + didFail: onFailure, + task: task + ) + } + } +} diff --git a/Sources/Later/Deferred/Deferred.swift b/Sources/Later/Deferred/Deferred.swift new file mode 100644 index 0000000..a10a3cc --- /dev/null +++ b/Sources/Later/Deferred/Deferred.swift @@ -0,0 +1,65 @@ +/// A `Deferred` represents a deferred asynchronous task that starts only when explicitly requested. +public struct Deferred: Sendable { + /// Errors that can occur with `Deferred`. + public enum DeferredError: Error, Sendable { + /// The deferred task was not started. + case deferredNotStarted + } + + private var task: Task? + private let startTask: @Sendable () async throws -> Value + private let didSucceed: (@Sendable (Value) -> Void)? + private let didFail: (@Sendable (Error) -> Void)? + + /// The value of the deferred task, which can be awaited asynchronously. + /// - Throws: An error if the deferred task has not started or if the task fails. + public var value: Value { + get async throws { + guard let task = task else { + throw DeferredError.deferredNotStarted + } + + return try await task.value + } + } + + /// Initializes a `Deferred` with a task starter closure and optional success and failure handlers. + /// - Parameters: + /// - didSucceed: A closure that is called when the task succeeds. + /// - didFail: A closure that is called when the task fails. + /// - task: The asynchronous task to be executed when start is called. + public init( + didSucceed: (@Sendable (Value) -> Void)? = nil, + didFail: (@Sendable (Error) -> Void)? = nil, + task: @escaping @Sendable () async throws -> Value + ) { + self.startTask = task + self.didSucceed = didSucceed + self.didFail = didFail + } + + /// Starts the deferred task. + public mutating func start() { + guard task == nil else { return } + + let startTask = startTask + let didSucceed = didSucceed + let didFail = didFail + + task = Task { + do { + let value = try await startTask() + didSucceed?(value) + return value + } catch { + didFail?(error) + throw error + } + } + } + + /// Cancels the deferred task. + public func cancel() { + task?.cancel() + } +} diff --git a/Sources/Later/Emitter/Emitter.swift b/Sources/Later/Emitter/Emitter.swift new file mode 100644 index 0000000..0f0633d --- /dev/null +++ b/Sources/Later/Emitter/Emitter.swift @@ -0,0 +1,26 @@ +/// An `Emitter` is responsible for emitting values to an associated stream. +public struct Emitter: Sendable { + private let continuation: AsyncThrowingStream.Continuation + private let emitAction: @Sendable (Value) -> Void + + /// Initializes a new `Emitter` with a continuation and an emit action. + /// + /// - Parameters: + /// - continuation: The continuation to yield values to. + /// - emitAction: An additional action to perform when emitting a value. + init( + continuation: AsyncThrowingStream.Continuation, + emitAction: @escaping @Sendable (Value) -> Void + ) { + self.continuation = continuation + self.emitAction = emitAction + } + + /// Emits a value to the associated stream. + /// + /// - Parameter value: The value to emit. + public func emit(value: Value) { + continuation.yield(value) + emitAction(value) + } +} diff --git a/Sources/Later/Extensions/Task+Schedule.swift b/Sources/Later/Extensions/Task+Schedule.swift new file mode 100644 index 0000000..bfc67a2 --- /dev/null +++ b/Sources/Later/Extensions/Task+Schedule.swift @@ -0,0 +1,34 @@ +extension Task { + /// Schedules a task to be executed after a specified duration. + /// + /// - Parameters: + /// - duration: The duration to wait before executing the task. + /// - tolerance: An optional tolerance for the duration. + /// - clock: The clock to use for measuring the duration. Defaults to `ContinuousClock`. + /// - task: The task to execute after the specified duration. + /// + /// - Throws: An error if the task fails or if the sleep is interrupted. + /// + /// - Note: This method is only available on macOS 13.0, iOS 16.0, watchOS 9.0, and tvOS 16.0 or newer. + /// + /// Example usage: + /// ```swift + /// do { + /// try await Task.schedule(for: .seconds(5)) { + /// print("Task executed after 5 seconds") + /// } + /// } catch { + /// print("Failed to execute task: \(error)") + /// } + /// ``` + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + public static func schedule( + for duration: TaskClock.Instant.Duration, + tolerance: TaskClock.Instant.Duration? = nil, + clock: TaskClock = ContinuousClock(), + task: @escaping () async throws -> Void + ) async throws where Success == Never, Failure == Never { + try await sleep(for: duration, tolerance: tolerance, clock: clock) + try await task() + } +} diff --git a/Sources/Later/Future/Future+Builder.swift b/Sources/Later/Future/Future+Builder.swift new file mode 100644 index 0000000..2cfdd93 --- /dev/null +++ b/Sources/Later/Future/Future+Builder.swift @@ -0,0 +1,51 @@ +extension Future { + /// A builder struct to construct a `Future` instance. + public class Builder { + private var onSuccess: (@Sendable (Value) -> Void)? + private var onFailure: (@Sendable (Error) -> Void)? + private let task: @Sendable () async throws -> Value + + /// Initializes the builder with an asynchronous task. + /// - Parameter task: The asynchronous task to be performed. + public init(task: @escaping @Sendable () async throws -> Value) { + self.task = task + } + + /// Sets the success closure. + /// - Parameter closure: A closure that is called when the task succeeds. + /// - Returns: The builder instance. + public func onSuccess(_ closure: @escaping @Sendable (Value) -> Void) -> Builder { + self.onSuccess = closure + return self + } + + /// Sets the failure closure. + /// - Parameter closure: A closure that is called when the task fails. + /// - Returns: The builder instance. + public func onFailure(_ closure: @escaping @Sendable (Error) -> Void) -> Builder { + self.onFailure = closure + return self + } + + /// Builds and returns a `Future` instance. + /// - Returns: A configured `Future` instance. + public func build() -> Future { + if let onSuccess, let onFailure { + return Future( + didSucceed: onSuccess, + didFail: onFailure, + task: task + ) + } else if let onSuccess { + return Future( + didSucceed: onSuccess, + task: task + ) + } else { + return Future( + task: task + ) + } + } + } +} diff --git a/Sources/Later/Future/Future.swift b/Sources/Later/Future/Future.swift new file mode 100644 index 0000000..a64f109 --- /dev/null +++ b/Sources/Later/Future/Future.swift @@ -0,0 +1,40 @@ +/// A `Future` represents a value that will be available at some point in the future. +/// It encapsulates an asynchronous task and provides a way to handle success and failure. +public struct Future: Sendable { + private let task: Task + + /// The value of the future, which can be awaited asynchronously. + public var value: Value { + get async throws { + try await task.value + } + } + + /// Initializes a `Future` with a task starter closure and optional success and failure handlers. + /// - Parameters: + /// - didSucceed: A closure that is called when the task succeeds. + /// - didFail: A closure that is called when the task fails. + /// - task: The asynchronous task to be executed when start is called. + @discardableResult + public init( + didSucceed: (@Sendable (Value) -> Void)? = nil, + didFail: (@Sendable (Error) -> Void)? = nil, + task: @escaping @Sendable () async throws -> Value + ) { + self.task = Task { + do { + let result = try await task() + didSucceed?(result) + return result + } catch { + didFail?(error) + throw error + } + } + } + + /// Cancels the task. + public func cancel() { + task.cancel() + } +} diff --git a/Sources/Later/Later.swift b/Sources/Later/Later.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/Later/Later.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Sources/Later/Publisher/AnySubscriber.swift b/Sources/Later/Publisher/AnySubscriber.swift new file mode 100644 index 0000000..a21067f --- /dev/null +++ b/Sources/Later/Publisher/AnySubscriber.swift @@ -0,0 +1,36 @@ +/// A type-erased wrapper for the `Subscribing` protocol. +class AnySubscriber: Subscribing { + private let didUpdate: (Any) -> Void + + init(_ subscriber: Subscriber) { + self.didUpdate = { value in + let mirror = Mirror(reflecting: value) + + if mirror.displayStyle != .optional { + guard + let value = value as? Subscriber.Value + else { return } + + return subscriber.didUpdate(newValue: value) + } + + if mirror.children.isEmpty { + return subscriber.didUpdate(newValue: nil) + } + + guard let (_, unwrappedValue) = mirror.children.first else { + return subscriber.didUpdate(newValue: nil) + } + + guard let value = unwrappedValue as? Subscriber.Value else { + return // Do no call update, this value isn't for this Subscriber + } + + subscriber.didUpdate(newValue: value) + } + } + + func didUpdate(newValue: Value?) { + didUpdate(newValue as Any) + } +} diff --git a/Sources/Later/Publisher/Publisher+Deferred.swift b/Sources/Later/Publisher/Publisher+Deferred.swift new file mode 100644 index 0000000..c048cfb --- /dev/null +++ b/Sources/Later/Publisher/Publisher+Deferred.swift @@ -0,0 +1,23 @@ +extension Publisher { + /// Initializes a `Deferred` and updates the observer's value when the deferred task completes. + /// - Parameters: + /// - didSucceed: A closure that is called when the deferred task completes successfully. + /// - didFail: A closure that is called when the deferred task fails. + /// - task: The asynchronous task to be executed. + /// - Returns: The initialized `Deferred`. + public func deferred( + didSucceed: (@Sendable (Value) -> Void)? = nil, + didFail: (@Sendable (Error) -> Void)? = nil, + task: @escaping @Sendable () async throws -> Value + ) -> Deferred { + Deferred( + didSucceed: didSucceed, + didFail: didFail, + task: { + let newValue = try await task() + await self.update(value: newValue) + return newValue + } + ) + } +} diff --git a/Sources/Later/Publisher/Publisher+Future.swift b/Sources/Later/Publisher/Publisher+Future.swift new file mode 100644 index 0000000..ee934d9 --- /dev/null +++ b/Sources/Later/Publisher/Publisher+Future.swift @@ -0,0 +1,19 @@ +extension Publisher { + /// Initializes an `Publisher` with a `Future` and updates its value when the future completes. + /// - Parameter future: The future to observe. + public func future( + didSucceed: (@Sendable (Value) -> Void)? = nil, + didFail: (@Sendable (Error) -> Void)? = nil, + task: @escaping @Sendable () async throws -> Value + ) -> Future { + Future( + didSucceed: didSucceed, + didFail: didFail, + task: { + let newValue = try await task() + await self.update(value: newValue) + return newValue + } + ) + } +} diff --git a/Sources/Later/Publisher/Publisher+Stream.swift b/Sources/Later/Publisher/Publisher+Stream.swift new file mode 100644 index 0000000..546d437 --- /dev/null +++ b/Sources/Later/Publisher/Publisher+Stream.swift @@ -0,0 +1,26 @@ +extension Publisher { + /// Initializes a `Stream` and updates the observer's value with each emitted value. + /// - Parameters: + /// - didSucceed: A closure that is called when the stream completes successfully. + /// - didFail: A closure that is called when the stream fails. + /// - task: The asynchronous task that produces values for the stream. + /// - Returns: The initialized `Stream`. + public func stream( + didSucceed: (@Sendable () -> Void)? = nil, + didFail: (@Sendable (Error) -> Void)? = nil, + task: @escaping @Sendable (Emitter) async throws -> Void + ) -> Stream { + let stream = Stream( + emitAction: { value in + Task { + await self.update(value: value) + } + }, + didSucceed: didSucceed, + didFail: didFail, + task: task + ) + + return stream + } +} diff --git a/Sources/Later/Publisher/Publisher.swift b/Sources/Later/Publisher/Publisher.swift new file mode 100644 index 0000000..d7000b5 --- /dev/null +++ b/Sources/Later/Publisher/Publisher.swift @@ -0,0 +1,73 @@ +/// A `Publisher` actor that allows objects to subscribe to changes in state or data. +public actor Publisher { + typealias ValueSubscriber = Publisher where Value == Value + + private var value: Value? + private var subscribers: [ObjectIdentifier: AnySubscriber] + + /// Gets the current value. + public var currentValue: Value? { + value + } + + /// Initializes a new `Publisher` with an optional initial value and an array of initial subscribers. + /// + /// - Parameters: + /// - initialValue: An optional initial value for the publisher. Defaults to `nil`. + /// - subscribers: An array of initial subscribers. Defaults to an empty array. + /// + /// This initializer sets the initial value of the publisher and registers the initial subscribers. + /// Each subscriber is identified by an `ObjectIdentifier` and stored in the `subscribers` dictionary. + /// + /// Example usage: + /// ```swift + /// class MySubscriber: Subscribing { + /// typealias Value = String + /// + /// func didUpdate(newValue: String?) { + /// print("New value: \(String(describing: newValue))") + /// } + /// } + /// + /// let subscriber = MySubscriber() + /// let publisher = Publisher(initialValue: "Hello", subscribers: [subscriber]) + /// ``` + public init( + initialValue value: Value? = nil, + subscribers: [any Subscribing] = [] + ) { + self.value = value + + var initialSubscribers: [ObjectIdentifier: AnySubscriber] = [:] + for subscriber in subscribers { + let id = ObjectIdentifier(subscriber) + initialSubscribers[id] = AnySubscriber(subscriber) + } + + self.subscribers = initialSubscribers + } + + /// Adds a subscriber to the publisher. + /// - Parameter subscriber: The subscriber that will be notified of value updates. + public func add(subscriber: some Subscribing) { + let id = ObjectIdentifier(subscriber) + subscribers[id] = AnySubscriber(subscriber) + subscribers[id]?.didUpdate(newValue: value) + } + + /// Removes a subscriber. + /// - Parameter subscriber: The subscriber to be removed. + public func remove(subscriber: some Subscribing) { + let id = ObjectIdentifier(subscriber) + subscribers.removeValue(forKey: id) + } + + /// Updates the value and notifies all subscribers. + /// - Parameter newValue: The new value to set. + internal func update(value newValue: Value?) { + value = newValue + for subscriber in subscribers.values { + subscriber.didUpdate(newValue: newValue) + } + } +} diff --git a/Sources/Later/Publisher/Subscribing.swift b/Sources/Later/Publisher/Subscribing.swift new file mode 100644 index 0000000..0b12e50 --- /dev/null +++ b/Sources/Later/Publisher/Subscribing.swift @@ -0,0 +1,8 @@ +/// A protocol that defines the methods for an observer. +public protocol Subscribing: AnyObject { + associatedtype Value: Sendable + + /// Notifies the observer of a value update. + /// - Parameter newValue: The new value. + func didUpdate(newValue: Value?) +} diff --git a/Sources/Later/SendableValue/SendableValue.swift b/Sources/Later/SendableValue/SendableValue.swift new file mode 100644 index 0000000..431cd8d --- /dev/null +++ b/Sources/Later/SendableValue/SendableValue.swift @@ -0,0 +1,34 @@ +import os + +@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) +public struct SendableValue: Sendable { + private let lockedValue: OSAllocatedUnfairLock + + public init(_ initialValue: Value) { + lockedValue = OSAllocatedUnfairLock(initialState: initialValue) + } + + public var value: Value { + get async { + await withCheckedContinuation { continuation in + lockedValue.withLock { value in + continuation.resume(returning: value) + } + } + } + } + + public func set(value newValue: Value) { + lockedValue.withLock { value in + value = newValue + } + } + + public func update(value updating: @Sendable (inout Value) -> Void) { + lockedValue.withLock { value in + var newValue = value + updating(&newValue) + value = newValue + } + } +} diff --git a/Sources/Later/Stream/Stream+Builder.swift b/Sources/Later/Stream/Stream+Builder.swift new file mode 100644 index 0000000..d3e7c3d --- /dev/null +++ b/Sources/Later/Stream/Stream+Builder.swift @@ -0,0 +1,54 @@ +extension Stream { + /// A builder class to construct a `Stream` instance. + public class Builder { + private var onSuccess: (@Sendable () -> Void)? + private var onFailure: (@Sendable (Error) -> Void)? + private let task: @Sendable (Emitter) async throws -> Void + + public init(task: @escaping @Sendable (Emitter) async throws -> Void) { + self.task = task + } + + /// Sets the success closure. + /// - Parameter closure: A closure that is called when the stream completes successfully. + /// - Returns: The builder instance. + public func onSuccess(_ closure: @escaping @Sendable () -> Void) -> Builder { + self.onSuccess = closure + return self + } + + /// Sets the failure closure. + /// - Parameter closure: A closure that is called when the stream fails. + /// - Returns: The builder instance. + public func onFailure(_ closure: @escaping @Sendable (Error) -> Void) -> Builder { + self.onFailure = closure + return self + } + + /// Builds and returns a `Stream` instance. + /// - Returns: A configured `Stream` instance. + public func build() -> Stream { + if let onSuccess, let onFailure { + return Stream( + didSucceed: onSuccess, + didFail: onFailure, + task: task + ) + } else if let onSuccess { + return Stream( + didSucceed: onSuccess, + task: task + ) + } else if let onFailure { + return Stream( + didFail: onFailure, + task: task + ) + } else { + return Stream( + task: task + ) + } + } + } +} diff --git a/Sources/Later/Stream/Stream.swift b/Sources/Later/Stream/Stream.swift new file mode 100644 index 0000000..9ed7527 --- /dev/null +++ b/Sources/Later/Stream/Stream.swift @@ -0,0 +1,148 @@ +/// A `Stream` represents an asynchronous sequence of values that are emitted over time. +final public class Stream: AsyncSequence, Sendable { + public typealias Element = Value + public typealias AsyncIterator = AsyncThrowingStream.AsyncIterator + + private let asyncStream: AsyncThrowingStream + + internal init( + emitAction: (@Sendable (Value) -> Void)? = nil, + didSucceed: (@Sendable () -> Void)? = nil, + didFail: (@Sendable (Error) -> Void)? = nil, + task: @escaping @Sendable (Emitter) async throws -> Void + ) { + asyncStream = AsyncThrowingStream { continuation in + Task { + do { + let emitter = Emitter( + continuation: continuation, + emitAction: emitAction ?? { _ in } + ) + + try await task(emitter) + didSucceed?() + continuation.finish() + } catch { + didFail?(error) + continuation.finish(throwing: error) + } + } + } + } + + /// Initializes a `Stream` with a task starter closure and optional success and failure handlers. + /// - Parameters: + /// - didSucceed: A closure that is called when the stream completes successfully. + /// - didFail: A closure that is called when the stream fails. + /// - task: The asynchronous task that produces values for the stream. + public convenience init( + didSucceed: (@Sendable () -> Void)? = nil, + didFail: (@Sendable (Error) -> Void)? = nil, + task: @escaping @Sendable (Emitter) async throws -> Void + ) { + self.init(emitAction: nil, didSucceed: didSucceed, didFail: didFail, task: task) + } + + /// Executes a given task for each value emitted by the stream. + /// - Parameter task: The task to be executed for each value. + public func forEach( + do task: @escaping (Value) async throws -> Void + ) async throws { + for try await value in self { + try await task(value) + } + } + + /// Transforms the values emitted by the stream using a given closure. + /// - Parameter transform: The closure to transform each value. + /// - Returns: A new stream with the transformed values. + public func map( + _ transform: @escaping @Sendable (Value) async throws -> Result + ) -> Stream { + Stream { emitter in + for try await value in self { + let newValue = try await transform(value) + emitter.emit(value: newValue) + } + } + } + + /// Filters the values emitted by the stream using a given closure. + /// - Parameter isIncluded: The closure to determine if a value should be included. + /// - Returns: A new stream with the filtered values. + public func filter( + _ isIncluded: @escaping @Sendable (Value) async throws -> Bool + ) -> Stream { + Stream { emitter in + for try await value in self { + if try await isIncluded(value) { + emitter.emit(value: value) + } + } + } + } + + /// Transforms the values emitted by the stream using a given closure and removes nil values. + /// - Parameter transform: The closure to transform each value. + /// - Returns: A new stream with the transformed and non-nil values. + public func compactMap( + _ transform: @escaping @Sendable (Value) async throws -> Result? + ) -> Stream { + Stream { emitter in + for try await value in self { + if let newValue = try await transform(value) { + emitter.emit(value: newValue) + } + } + } + } + + /// Reduces the values emitted by the stream to a single value using a given closure. + /// - Parameters: + /// - initialResult: The initial value for the reduction. + /// - nextPartialResult: The closure to combine the current result and the next value. + /// - Returns: The reduced value. + public func reduce( + _ initialResult: Result, + _ nextPartialResult: @escaping (Result, Value) async throws -> Result + ) async throws -> Result { + var result = initialResult + for try await value in self { + result = try await nextPartialResult(result, value) + } + return result + } + + /// Reduces the values emitted by the stream into the provided initial result using a given closure. + /// - Parameters: + /// - initialResult: The initial value for the reduction. + /// - updateAccumulatingResult: The closure to update the accumulating result with the next value. + /// - Returns: The reduced value. + public func reduce( + into initialResult: Result, + _ updateAccumulatingResult: @escaping (inout Result, Value) async throws -> Void + ) async throws -> Result { + var result = initialResult + for try await value in self { + try await updateAccumulatingResult(&result, value) + } + return result + } + + /// Collects all the values emitted by the stream into an array. + /// - Returns: An array of all the values emitted by the stream. + public func intoArray() async throws -> [Value] { + var values: [Value] = [] + for try await value in self { + values.append(value) + } + return values + } + + // MARK: - AsyncSequence + + /// Provides an asynchronous iterator for the stream. + public func makeAsyncIterator() -> AsyncThrowingStream.AsyncIterator { + asyncStream.makeAsyncIterator() + } +} diff --git a/Tests/LaterTests/Deferred/DeferredBuilderTests.swift b/Tests/LaterTests/Deferred/DeferredBuilderTests.swift new file mode 100644 index 0000000..f5340d2 --- /dev/null +++ b/Tests/LaterTests/Deferred/DeferredBuilderTests.swift @@ -0,0 +1,54 @@ +import Testing +@testable import Later + +struct DeferredBuilderTests { + enum DeferredError: Error { + case mockError + } + + @Test func testDeferredSuccess() async throws { + var deferred = Deferred + .Builder { + try await Task.catNap() + return "Data fetched" + } + .onSuccess { value in + #expect(value == "Data fetched") + } + .onFailure { _ in + Issue.record("Deferred should not fail") + } + .build() + + deferred.start() + + let result = try await deferred.value + #expect(result == "Data fetched") + } + + @Test func testDeferredFailure() async throws { + var deferred = Deferred + .Builder { + try await Task.catNap() + throw DeferredError.mockError + } + .onSuccess { _ in + Issue.record("Deferred should not succeed") + } + .onFailure { error in + let error = try? #require(error as? DeferredError) + #expect(error == DeferredError.mockError) + } + .build() + + deferred.start() + + do { + _ = try await deferred.value + Issue.record("Task should throw an error") + } catch { + let error = try #require(error as? DeferredError) + #expect(error == DeferredError.mockError) + } + } +} diff --git a/Tests/LaterTests/Deferred/DeferredTests.swift b/Tests/LaterTests/Deferred/DeferredTests.swift new file mode 100644 index 0000000..4d4f2dc --- /dev/null +++ b/Tests/LaterTests/Deferred/DeferredTests.swift @@ -0,0 +1,72 @@ +import Testing +@testable import Later + +struct DeferredTests { + enum DeferredError: Error { + case mockError + } + + @Test func testDeferredSuccess() async throws { + var deferred = Deferred( + didSucceed: { value in + #expect(value == "Data fetched") + }, + didFail: { _ in + Issue.record("Deferred should not fail") + }, + task: { + try await Task.catNap() + return "Data fetched" + } + ) + + deferred.start() + + let result = try await deferred.value + #expect(result == "Data fetched") + } + + @Test func testDeferredFailure() async throws { + var deferred = Deferred( + didSucceed: { _ in + Issue.record("Deferred should not succeed") + }, + didFail: { error in + let error = try? #require(error as? DeferredError) + #expect(error == DeferredError.mockError) + }, + task: { + try await Task.catNap() + throw DeferredError.mockError + } + ) + + deferred.start() + + do { + _ = try await deferred.value + Issue.record("Task should throw an error") + } catch { + let error = try #require(error as? DeferredError) + #expect(error == DeferredError.mockError) + } + } + + @Test func testDeferredCancellation() async throws { + var deferred = Deferred { + try await Task.catNap() + return "Completed" + } + + deferred.start() + + deferred.cancel() + + do { + _ = try await deferred.value + Issue.record("Deferred should have been canceled") + } catch { + #expect((error as? CancellationError) != nil) + } + } +} diff --git a/Tests/LaterTests/Future/FutureBuilderTests.swift b/Tests/LaterTests/Future/FutureBuilderTests.swift new file mode 100644 index 0000000..8b81c7d --- /dev/null +++ b/Tests/LaterTests/Future/FutureBuilderTests.swift @@ -0,0 +1,77 @@ +import Testing +@testable import Later + +struct FutureBuilderTests { + enum FutureError: Error { + case mockError + } + + @Test func testFutureTask() async throws { + let future = Future.Builder { + try await Task.catNap() + return "Data fetched" + }.build() + + let result = try await future.value + #expect(result == "Data fetched") + } + + @Test func testFutureSuccess() async throws { + let builder = Future.Builder { + try await Task.catNap() + return "Data fetched" + } + + let future = builder + .onSuccess { value in + #expect(value == "Data fetched") + } + .onFailure { _ in + Issue.record("Task should not fail") + } + .build() + + let result = try await future.value + #expect(result == "Data fetched") + } + + @Test func testFutureBuilderCompletesSuccessfully() async throws { + let future = Future + .Builder { + try await Task.catNap() + return "Completed" + } + .onSuccess { value in + #expect(value == "Completed") + } + .build() + + let result = try await future.value + #expect(result == "Completed") + } + + @Test func testFutureFailure() async throws { + let builder = Future.Builder { + try await Task.catNap() + throw FutureError.mockError + } + + let future = builder + .onSuccess { _ in + Issue.record("Task should not succeed") + } + .onFailure { error in + let error = try? #require(error as? FutureError) + #expect(error == FutureError.mockError) + } + .build() + + do { + _ = try await future.value + Issue.record("Task should throw an error") + } catch { + let error = try #require(error as? FutureError) + #expect(error == FutureError.mockError) + } + } +} diff --git a/Tests/LaterTests/Future/FutureTests.swift b/Tests/LaterTests/Future/FutureTests.swift new file mode 100644 index 0000000..f8330d8 --- /dev/null +++ b/Tests/LaterTests/Future/FutureTests.swift @@ -0,0 +1,73 @@ +import Testing +@testable import Later + +struct FutureTests { + enum FutureError: Error { + case mockError + } + + @Test func testFutureTask() async throws { + let future = Future { + try await Task.catNap() + return "Data fetched" + } + + let result = try await future.value + #expect(result == "Data fetched") + } + + @Test func testFutureSuccess() async throws { + let future = Future( + didSucceed: { value in + #expect(value == "Data fetched") + }, + task: { + try await Task.catNap() + return "Data fetched" + } + ) + + let result = try await future.value + #expect(result == "Data fetched") + } + + @Test func testFutureFailure() async throws { + let future = Future( + didSucceed: { _ in + Issue.record("Task should not succeed") + }, + didFail: { error in + let error = try? #require(error as? FutureError) + #expect(error == FutureError.mockError) + }, + task: { + try await Task.catNap() + throw FutureError.mockError + } + ) + + do { + _ = try await future.value + Issue.record("Task should throw an error") + } catch { + let error = try #require(error as? FutureError) + #expect(error == FutureError.mockError) + } + } + + @Test func testFutureCancellation() async throws { + let future = Future { + try await Task.catNap() + return "Completed" + } + + future.cancel() + + do { + _ = try await future.value + Issue.record("Future should have been canceled") + } catch { + _ = try #require(error as? CancellationError) + } + } +} diff --git a/Tests/LaterTests/Helpers/Testing.swift b/Tests/LaterTests/Helpers/Testing.swift new file mode 100644 index 0000000..1cb752f --- /dev/null +++ b/Tests/LaterTests/Helpers/Testing.swift @@ -0,0 +1,7 @@ +import Testing + +extension Task where Success == Never, Failure == Never { + static func catNap() async throws { + try await Task.schedule(for: .milliseconds(100), task: {}) + } +} diff --git a/Tests/LaterTests/LaterTests.swift b/Tests/LaterTests/LaterTests.swift deleted file mode 100644 index d349408..0000000 --- a/Tests/LaterTests/LaterTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import Later - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} diff --git a/Tests/LaterTests/Publisher/PublisherTests.swift b/Tests/LaterTests/Publisher/PublisherTests.swift new file mode 100644 index 0000000..a3e4403 --- /dev/null +++ b/Tests/LaterTests/Publisher/PublisherTests.swift @@ -0,0 +1,324 @@ +import Testing +@testable import Later + +final class TestSubscriber: Subscribing, Sendable { + typealias Value = String + let observedValues: SendableValue<[String?]> = SendableValue([]) + + func didUpdate(newValue: String?) { + observedValues.update { values in + values.append(newValue) + } + } +} + +struct PublisherTests { + enum TestError: Error { + case mockError + } + + @Test func testPublisherNotifiesSubscribers() async throws { + let publisher = Publisher(initialValue: "Initial value") + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + await publisher.update(value: "Updated value") + + let values = await testSubscriber.observedValues.value + #expect(values == ["Initial value", "Updated value"]) + } + + @Test func testPublisherHandlesMultipleSubscribers() async throws { + let publisher = Publisher(initialValue: "Initial value") + let testSubscriber1 = TestSubscriber() + let testSubscriber2 = TestSubscriber() + + await publisher.add(subscriber: testSubscriber1) + await publisher.add(subscriber: testSubscriber2) + + await publisher.update(value: "Updated value") + + let values1 = await testSubscriber1.observedValues.value + let values2 = await testSubscriber2.observedValues.value + + #expect(values1 == ["Initial value", "Updated value"]) + #expect(values2 == ["Initial value", "Updated value"]) + } + + @Test func testPublisherHandlesNoObservers() async throws { + let publisher = Publisher(initialValue: "Initial value") + await publisher.update(value: "Updated value") + + let currentValue = await publisher.currentValue + #expect(currentValue == "Updated value") + } + + @Test func testPublisherWithFuture() async throws { + let publisher = Publisher() + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + let future = await publisher.future( + didSucceed: nil, + didFail: nil, + task: { + try await Task.catNap() + return "Future completed" + } + ) + + let value = try await future.value + let values = await testSubscriber.observedValues.value + + #expect(values == [nil, "Future completed"]) + #expect(value == "Future completed") + } + + @Test func testPublisherWithDeferred() async throws { + let publisher = Publisher() + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + var deferred = await publisher.deferred( + didSucceed: nil, + didFail: nil, + task: { + try await Task.catNap() + return "Deferred completed" + } + ) + + deferred.start() + + let value = try await deferred.value + let values = await testSubscriber.observedValues.value + + #expect(values == [nil, "Deferred completed"]) + #expect(value == "Deferred completed") + } + + @Test func testPublisherWithStream() async throws { + let publisher = Publisher() + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + let stream = await publisher.stream( + didSucceed: nil, + didFail: nil, + task: { emitter in + try await Task.catNap() + emitter.emit(value: "Stream value 1") + try await Task.catNap() + emitter.emit(value: "Stream value 2") + } + ) + + // Collect stream values + var streamValues: [String?] = [] + + for try await value in stream { + streamValues.append(value) + } + + // Wait for the stream to emit values + try await Task.catNap() + + let values = await testSubscriber.observedValues.value + #expect(values == [nil, "Stream value 1", "Stream value 2"]) + #expect(streamValues == ["Stream value 1", "Stream value 2"]) + } + + @Test func testPublisherRemovesObservers() async throws { + let publisher = Publisher(initialValue: "Initial value") + let testSubscriber1 = TestSubscriber() + let testSubscriber2 = TestSubscriber() + + await publisher.add(subscriber: testSubscriber1) + await publisher.add(subscriber: testSubscriber2) + await publisher.update(value: "Updated value") + + await publisher.remove(subscriber: testSubscriber1) + await publisher.update(value: "Final value") + + let values1 = await testSubscriber1.observedValues.value + let values2 = await testSubscriber2.observedValues.value + + #expect(values1 == ["Initial value", "Updated value"]) + #expect(values2 == ["Initial value", "Updated value", "Final value"]) + } + + @Test func testPublisherHandlesObserverRemovalDuringUpdate() async throws { + let publisher = Publisher(initialValue: "Initial value") + let testSubscriber1 = TestSubscriber() + let testSubscriber2 = TestSubscriber() + + await publisher.add(subscriber: testSubscriber1) + await publisher.add(subscriber: testSubscriber2) + + await publisher.update(value: "Updated value") + await publisher.remove(subscriber: testSubscriber1) + await publisher.update(value: "Final value") + + let values1 = await testSubscriber1.observedValues.value + let values2 = await testSubscriber2.observedValues.value + + #expect(values1 == ["Initial value", "Updated value"]) + #expect(values2 == ["Initial value", "Updated value", "Final value"]) + } + + @Test func testPublisherAddedAfterValueChange() async throws { + let publisher = Publisher(initialValue: "Initial value") + await publisher.update(value: "Updated value") + + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + let values = await testSubscriber.observedValues.value + #expect(values == ["Updated value"]) + } + + @Test func testPublisherConcurrentUpdates() async throws { + let publisher = Publisher() + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + let tasks = (1...10).map { i in + Task { + await publisher.update(value: "Update \(i)") + } + } + + for task in tasks { + await task.value + } + + let values = await testSubscriber.observedValues.value + #expect(values.count == 11) // 1 initial nil + 10 updates + } + + @Test func testPublisherHandlesNilValues() async throws { + let publisher = Publisher() + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + await publisher.update(value: nil) + await publisher.update(value: "Non-nil value") + await publisher.update(value: nil) + + let values = await testSubscriber.observedValues.value + #expect(values == [nil, nil, "Non-nil value", nil]) + } + + @Test func testPublisherMultipleRapidUpdates() async throws { + let publisher = Publisher() + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + await publisher.update(value: "Value 1") + await publisher.update(value: "Value 2") + await publisher.update(value: "Value 3") + + let values = await testSubscriber.observedValues.value + #expect(values == [nil, "Value 1", "Value 2", "Value 3"]) + } + + @Test func testPublisherConcurrentAddRemoveObservers() async throws { + let publisher = Publisher() + let testSubscriber = TestSubscriber() + + let addRemoveTasks = (1...10).map { _ in + Task { + await publisher.add(subscriber: testSubscriber) + await publisher.remove(subscriber: testSubscriber) + } + } + + for task in addRemoveTasks { + await task.value + } + + // Add observer at the end to ensure it is observing the final state + await publisher.add(subscriber: testSubscriber) + await publisher.update(value: "Final value") + + let values = await testSubscriber.observedValues.value + #expect(values.last == "Final value") + #expect(values.count == 12) // Expect 10 nils from concurrent adds/removes + final add & final value + } + + @Test func testPublisherConcurrentAddObserversDuringUpdate() async throws { + let publisher = Publisher() + let testSubscriber1 = TestSubscriber() + let testSubscriber2 = TestSubscriber() + + let concurrentTasks = (1...5).map { i in + Task { + await publisher.add(subscriber: testSubscriber1) + await publisher.update(value: "Update \(i)") + } + } + + // Await completion of concurrent tasks + for task in concurrentTasks { + await task.value + } + + await publisher.remove(subscriber: testSubscriber1) + + // Add observer at the end to ensure it is observing the final state + await publisher.add(subscriber: testSubscriber2) + await publisher.update(value: "Final value") + + let values1 = await testSubscriber1.observedValues.value + let values2 = await testSubscriber2.observedValues.value + + // Ensure values1 contains all updates except for the final + #expect(values1.count == 10) + #expect(values1.contains(nil)) + #expect(values1.contains("Update 1")) + #expect(values1.contains("Update 2")) + #expect(values1.contains("Update 3")) + #expect(values1.contains("Update 4")) + #expect(values1.contains("Update 5")) + + // Ensure values2 contains "Final value" + #expect(values2.count == 2) + #expect(values2.contains("Final value")) + } + + @Test func testPublisherHighContention() async throws { + let publisher = Publisher() + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + let tasks = (1...100).map { i in + Task { + if i % 2 == 0 { + await publisher.update(value: "Even \(i)") + } else { + await publisher.update(value: "Odd \(i)") + } + } + } + + for task in tasks { + await task.value + } + + let values = await testSubscriber.observedValues.value + #expect(values.count == 101) // 1 initial nil + 100 updates + } + + @Test func testPublisherEdgeCaseHandling() async throws { + let publisher = Publisher(initialValue: "Initial value") + let testSubscriber = TestSubscriber() + await publisher.add(subscriber: testSubscriber) + + await publisher.update(value: nil) + await publisher.update(value: "Edge case value") + await publisher.update(value: nil) + + let values = await testSubscriber.observedValues.value + #expect(values == ["Initial value", nil, "Edge case value", nil]) + } +} diff --git a/Tests/LaterTests/SendableValue/SendableValueTests.swift b/Tests/LaterTests/SendableValue/SendableValueTests.swift new file mode 100644 index 0000000..258ea73 --- /dev/null +++ b/Tests/LaterTests/SendableValue/SendableValueTests.swift @@ -0,0 +1,169 @@ +import Testing +@testable import Later + +struct SendableValueTests { + @Test func testSendableValueInitialValue() async throws { + let sendableValue = SendableValue(42) + let value = await sendableValue.value + #expect(value == 42) + } + + @Test func testSendableValueSetValue() async throws { + let sendableValue = SendableValue(42) + sendableValue.set(value: 100) + let value = await sendableValue.value + #expect(value == 100) + } + + @Test func testSendableValueUpdateValue() async throws { + let sendableValue = SendableValue(42) + sendableValue.update { $0 += 1 } + let value = await sendableValue.value + #expect(value == 43) + } + + @Test func testSendableValueConcurrentAccess() async throws { + let sendableValue = SendableValue(0) + let tasks = (1 ... 10).map { i in + Task { + sendableValue.update { $0 += i } + } + } + + for task in tasks { + await task.value + } + + let value = await sendableValue.value + #expect(value == 55) // Sum of integers from 1 to 10 + } + + @Test func testSendableValueConcurrentSets() async throws { + let sendableValue = SendableValue(0) + let tasks = (1 ... 10).map { i in + Task { + sendableValue.set(value: i) + } + } + + for task in tasks { + await task.value + } + + let value = await sendableValue.value + #expect((1 ... 10).contains(value)) + } + +@Test func testSendableValueMixedConcurrentOperations() async throws { + let sendableValue = SendableValue(0) + let setTasks = (1 ... 5).map { i in + Task { + sendableValue.set(value: i) + } + } + let updateTasks = (6 ... 10).map { i in + Task { + sendableValue.update { $0 += i } + } + } + + let allTasks = setTasks + updateTasks + for task in allTasks { + await task.value + } + + let value = await sendableValue.value + + // Set values could be overwritten, and update values could vary widely due to concurrency + // The range includes a reasonable set of expected results considering the operations. + #expect((1 ... 55).contains(value)) +} + + @Test func testSendableValueHighContention() async throws { + let sendableValue = SendableValue(0) + let tasks = (1 ... 1000).map { i in + Task { + if i % 2 == 0 { + sendableValue.set(value: i) + } else { + sendableValue.update { $0 += i } + } + } + } + + for task in tasks { + await task.value + } + + let value = await sendableValue.value + #expect(value >= 0) // We expect the value to be non-negative + } + + @Test func testSendableValueEdgeCase() async throws { + let sendableValue = SendableValue(Int.max - 1) + sendableValue.update { $0 += 1 } + let value = await sendableValue.value + #expect(value == Int.max) + } + + @Test func testSendableValueUnderflow() async throws { + let sendableValue = SendableValue(Int.min + 1) + sendableValue.update { $0 -= 1 } + let value = await sendableValue.value + #expect(value == Int.min) + } + + @Test func testSendableValueConcurrentIncrements() async throws { + let sendableValue = SendableValue(0) + let tasks = (1 ... 1000).map { _ in + Task { + sendableValue.update { $0 += 1 } + } + } + + for task in tasks { + await task.value + } + + let value = await sendableValue.value + #expect(value == 1000) + } + + @Test func testSendableValueConcurrentDecrements() async throws { + let sendableValue = SendableValue(1000) + let tasks = (1 ... 1000).map { _ in + Task { + sendableValue.update { $0 -= 1 } + } + } + + for task in tasks { + await task.value + } + + let value = await sendableValue.value + #expect(value == 0) + } + + @Test func testSendableValueConcurrentMixedIncrementsAndDecrements() async throws { + let sendableValue = SendableValue(0) + let incrementTasks = (1 ... 500).map { _ in + Task { + sendableValue.update { $0 += 1 } + } + } + let decrementTasks = (1 ... 500).map { _ in + Task { + sendableValue.update { $0 -= 1 } + } + } + + let allTasks = incrementTasks + decrementTasks + for task in allTasks { + await task.value + } + + let value = await sendableValue.value + #expect(value == 0) + } +} diff --git a/Tests/LaterTests/Stream/StreamBuilderTests.swift b/Tests/LaterTests/Stream/StreamBuilderTests.swift new file mode 100644 index 0000000..298ac7e --- /dev/null +++ b/Tests/LaterTests/Stream/StreamBuilderTests.swift @@ -0,0 +1,164 @@ +import Testing +@testable import Later + +struct StreamBuilderTests { + enum StreamError: Error { + case mockError + } + + @Test func testStreamBuilderEmitsValues() async throws { + let stream = Stream + .Builder { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + .onSuccess { + #expect(true) + } + .onFailure { _ in + Issue.record("Stream should not fail") + } + .build() + + var values = [String]() + do { + for try await value in stream { + values.append(value) + } + } catch { + Issue.record("Stream error: \(error)") + } + + #expect(values == ["First value", "Second value"]) + } + + @Test func testStreamBuilderHandlesCompletion() async throws { + let stream = Stream + .Builder { emitter in + try await Task.catNap() + emitter.emit(value: "Only value") + } + .onSuccess { + #expect(true) + } + .onFailure { _ in + Issue.record("Stream should not fail") + } + .build() + + var values = [String]() + do { + for try await value in stream { + values.append(value) + } + } catch { + Issue.record("Stream error: \(error)") + } + + #expect(values == ["Only value"]) + } + + @Test func testStreamBuilderHandlesErrors() async throws { + let stream = Stream + .Builder { emitter in + try await Task.catNap() + throw StreamError.mockError + } + .onSuccess { + Issue.record("Stream should not complete successfully") + } + .onFailure { error in + #expect((error as! StreamError) == .mockError) + } + .build() + + var values = [String]() + var receivedError: Error? + + do { + for try await value in stream { + values.append(value) + } + } catch { + receivedError = error + } + + #expect(values.isEmpty) + #expect(receivedError as? StreamError == .mockError) + } + + @Test func testStreamBuilderEmitsValuesWithSuccessOnly() async throws { + let stream = Stream + .Builder { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + .onSuccess { + #expect(true) + } + .build() + + var values = [String]() + do { + for try await value in stream { + values.append(value) + } + } catch { + Issue.record("Stream error: \(error)") + } + + #expect(values == ["First value", "Second value"]) + } + + @Test func testStreamBuilderEmitsValuesWithFailureOnly() async throws { + let stream = Stream + .Builder { emitter in + try await Task.catNap() + throw StreamError.mockError + } + .onFailure { error in + #expect((error as! StreamError) == .mockError) + } + .build() + + var values = [String]() + var receivedError: Error? + + do { + for try await value in stream { + values.append(value) + } + } catch { + receivedError = error + } + + #expect(values.isEmpty) + #expect(receivedError as? StreamError == .mockError) + } + + @Test func testStreamBuilderEmitsValuesWithTaskOnly() async throws { + let stream = Stream + .Builder { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + .build() + + var values = [String]() + do { + for try await value in stream { + values.append(value) + } + } catch { + Issue.record("Stream error: \(error)") + } + + #expect(values == ["First value", "Second value"]) + } +} diff --git a/Tests/LaterTests/Stream/StreamTests.swift b/Tests/LaterTests/Stream/StreamTests.swift new file mode 100644 index 0000000..8321561 --- /dev/null +++ b/Tests/LaterTests/Stream/StreamTests.swift @@ -0,0 +1,123 @@ +import Testing +@testable import Later + +struct StreamTests { + @Test func testStreamEmitsValues() async throws { + let stream = Stream { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + + var values: [String] = [] + try await stream.forEach { value in + values.append(value) + } + + #expect(values == ["First value", "Second value"]) + } + + @Test func testStreamMap() async throws { + let stream = Stream { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + + let mappedStream = stream.map { value in + "Mapped \(value)" + } + + var values: [String] = [] + try await mappedStream.forEach { value in + values.append(value) + } + + #expect(values == ["Mapped First value", "Mapped Second value"]) + } + + @Test func testStreamFilter() async throws { + let stream = Stream { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + + let filteredStream = stream.filter { value in + value.contains("First") + } + + var values: [String] = [] + try await filteredStream.forEach { value in + values.append(value) + } + + #expect(values == ["First value"]) + } + + @Test func testStreamCompactMap() async throws { + let stream = Stream { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + + let compactMappedStream = stream.compactMap { value in + value.contains("Second") ? nil : "CompactMapped \(value)" + } + + var values: [String] = [] + try await compactMappedStream.forEach { value in + values.append(value) + } + + #expect(values == ["CompactMapped First value"]) + } + + @Test func testStreamReduce() async throws { + let stream = Stream { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + + let result = try await stream.reduce("") { result, value in + result + value + " " + } + + #expect(result == "First value Second value ") + } + + @Test func testStreamReduceInto() async throws { + let stream = Stream { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + + let result = try await stream.reduce(into: "") { result, value in + result += value + " " + } + + #expect(result == "First value Second value ") + } + + @Test func testStreamIntoArray() async throws { + let stream = Stream { emitter in + try await Task.catNap() + emitter.emit(value: "First value") + try await Task.catNap() + emitter.emit(value: "Second value") + } + + let values = try await stream.intoArray() + + #expect(values == ["First value", "Second value"]) + } +}