From e8bacbba28049b18b3ccc0533b5db5f07043ad78 Mon Sep 17 00:00:00 2001 From: Brandon Williams <135203+mbrandonw@users.noreply.github.com> Date: Wed, 29 Jun 2022 13:58:23 -0400 Subject: [PATCH] =?UTF-8?q?=E2=8F=B0=20Clocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stephen Celis --- .github/workflows/ci.yml | 30 ++ .github/workflows/documentation.yml | 74 ++++ .github/workflows/format.yml | 31 ++ .gitignore | 5 + .../contents.xcworkspacedata | 7 + .../xcshareddata/xcschemes/Clocks.xcscheme | 77 ++++ .../xcschemes/swift-clocks.xcscheme | 91 +++++ LICENSE | 21 ++ Makefile | 17 + Package.resolved | 41 ++ Package.swift | 51 +++ README.md | 351 ++++++++++++++++++ Sources/Clocks/Documentation.docc/Clocks.md | 28 ++ .../Documentation.docc/ImmediateClock.md | 8 + .../Clocks/Documentation.docc/TestClock.md | 19 + .../Documentation.docc/UnimplementedClock.md | 8 + Sources/Clocks/ImmediateClock.swift | 178 +++++++++ Sources/Clocks/Internal/Lock.swift | 21 ++ Sources/Clocks/Internal/Yield.swift | 10 + Sources/Clocks/Internal/_AnyClock.swift | 44 +++ .../Clocks/Internal/_AsyncTimerSequence.swift | 83 +++++ Sources/Clocks/Shims.swift | 17 + Sources/Clocks/SwiftUI.swift | 26 ++ Sources/Clocks/TestClock.swift | 270 ++++++++++++++ Sources/Clocks/Timer.swift | 12 + Sources/Clocks/UnimplementedClock.swift | 152 ++++++++ Tests/ClocksTests/AsyncAlgorithmsTests.swift | 166 +++++++++ Tests/ClocksTests/ImmediateClockTests.swift | 43 +++ .../ClocksTests/Internal/ActorIsolated.swift | 24 ++ Tests/ClocksTests/Internal/AsyncStream.swift | 9 + Tests/ClocksTests/ShimTests.swift | 24 ++ Tests/ClocksTests/TestClocksTests.swift | 194 ++++++++++ .../ClocksTests/UnimplementedClockTests.swift | 71 ++++ 33 files changed, 2203 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/documentation.yml create mode 100644 .github/workflows/format.yml create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Clocks.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/swift-clocks.xcscheme create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/Clocks/Documentation.docc/Clocks.md create mode 100644 Sources/Clocks/Documentation.docc/ImmediateClock.md create mode 100644 Sources/Clocks/Documentation.docc/TestClock.md create mode 100644 Sources/Clocks/Documentation.docc/UnimplementedClock.md create mode 100644 Sources/Clocks/ImmediateClock.swift create mode 100644 Sources/Clocks/Internal/Lock.swift create mode 100644 Sources/Clocks/Internal/Yield.swift create mode 100644 Sources/Clocks/Internal/_AnyClock.swift create mode 100644 Sources/Clocks/Internal/_AsyncTimerSequence.swift create mode 100644 Sources/Clocks/Shims.swift create mode 100644 Sources/Clocks/SwiftUI.swift create mode 100644 Sources/Clocks/TestClock.swift create mode 100644 Sources/Clocks/Timer.swift create mode 100644 Sources/Clocks/UnimplementedClock.swift create mode 100644 Tests/ClocksTests/AsyncAlgorithmsTests.swift create mode 100644 Tests/ClocksTests/ImmediateClockTests.swift create mode 100644 Tests/ClocksTests/Internal/ActorIsolated.swift create mode 100644 Tests/ClocksTests/Internal/AsyncStream.swift create mode 100644 Tests/ClocksTests/ShimTests.swift create mode 100644 Tests/ClocksTests/TestClocksTests.swift create mode 100644 Tests/ClocksTests/UnimplementedClockTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..ac370f72 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + library: + runs-on: macos-12 + strategy: + matrix: + xcode: ['14.0.1'] + config: ['debug', 'release'] + steps: + - uses: actions/checkout@v3 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Run ${{ matrix.config }} tests + run: CONFIG=${{ matrix.config }} make test + +# TODO: test on linux diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml new file mode 100644 index 00000000..95e824af --- /dev/null +++ b/.github/workflows/documentation.yml @@ -0,0 +1,74 @@ +# Build and deploy DocC to GitHub pages. Based off of @karwa's work here: +# https://github.com/karwa/swift-url/blob/main/.github/workflows/docs.yml +name: Documentation + +on: + release: + types: + - published + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-12 + steps: + - name: Select Xcode 14.1 + run: sudo xcode-select -s /Applications/Xcode_14.1.app + + - name: Checkout Package + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Checkout gh-pages Branch + uses: actions/checkout@v2 + with: + ref: gh-pages + path: docs-out + + - name: Build documentation + run: > + rm -rf docs-out/.git; + rm -rf docs-out/main; + git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | tail -n +6 | xargs -I {} rm -rf {}; + + for tag in $(echo "main"; git tag -l --sort=-v:refname | grep -e "\d\+\.\d\+.0" | head -6); + do + if [ -d "docs-out/$tag/data/documentation/swiftclocks" ] + then + echo "✅ Documentation for "$tag" already exists."; + else + echo "⏳ Generating documentation for Clocks @ "$tag" release."; + rm -rf "docs-out/$tag"; + + git checkout .; + git checkout "$tag"; + + swift package \ + --allow-writing-to-directory docs-out/"$tag" \ + generate-documentation \ + --target Clocks \ + --output-path docs-out/"$tag" \ + --transform-for-static-hosting \ + --hosting-base-path /swift-clocks/"$tag" \ + && echo "✅ Documentation generated for Clocks @ "$tag" release." \ + || echo "⚠️ Documentation skipped for Clocks @ "$tag"."; + fi; + done + + - name: Fix permissions + run: 'sudo chown -R $USER docs-out' + + - name: Publish documentation to GitHub Pages + uses: JamesIves/github-pages-deploy-action@4.1.7 + with: + branch: gh-pages + folder: docs-out + single-commit: true diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000..370a2e52 --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,31 @@ +name: Format + +on: + push: + branches: + - main + +concurrency: + group: format-${{ github.ref }} + cancel-in-progress: true + +jobs: + swift_format: + name: swift-format + runs-on: macos-12 + steps: + - uses: actions/checkout@v2 + - name: Xcode Select + run: sudo xcode-select -s /Applications/Xcode_14.0.1.app + - name: Tap + run: brew tap pointfreeco/formulae + - name: Install + run: brew install Formulae/swift-format@5.7 + - name: Format + run: make format + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: Run swift-format + branch: 'main' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..95c43209 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Clocks.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Clocks.xcscheme new file mode 100644 index 00000000..3de631d4 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Clocks.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/swift-clocks.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/swift-clocks.xcscheme new file mode 100644 index 00000000..121ba1ee --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/swift-clocks.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..3baf9dae --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Point-Free + +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/Makefile b/Makefile new file mode 100644 index 00000000..d4b83dcc --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +CONFIG = debug +PLATFORM = iOS Simulator,name=iPhone 13 Pro + +test: + xcodebuild test \ + -configuration $(CONFIG) \ + -scheme swift-clocks \ + -destination platform="$(PLATFORM)" + +format: + swift format \ + --ignore-unparsable-files \ + --in-place \ + --recursive \ + ./Package.swift ./Sources ./Tests + +.PHONY: format test diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..89de3670 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "branch" : "cf70e78", + "revision" : "cf70e78632e990cd041fef21044e54fa5fdd1c56" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "f504716c27d2e5d4144fa4794b12129301d17729", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "3303b164430d9a7055ba484c8ead67a52f7b74f6", + "version" : "1.0.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "f821dcbac7cb6913f8e0d1a80496d0ba0199fa81", + "version" : "0.3.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..4dc71bf0 --- /dev/null +++ b/Package.swift @@ -0,0 +1,51 @@ +// swift-tools-version: 5.6 + +import PackageDescription + +let package = Package( + name: "swift-clocks", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6), + ], + products: [ + .library( + name: "Clocks", + targets: ["Clocks"] + ) + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "0.3.0"), + .package(url: "https://github.com/apple/swift-async-algorithms", revision: "cf70e78"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + ], + targets: [ + .target( + name: "Clocks", + dependencies: [ + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay") + ], + swiftSettings: [ + .unsafeFlags([ + "-Xfrontend", "-warn-concurrency", + "-Xfrontend", "-enable-actor-data-race-checks", + ]) + ] + ), + .testTarget( + name: "ClocksTests", + dependencies: [ + "Clocks", + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + ], + swiftSettings: [ + .unsafeFlags([ + "-Xfrontend", "-warn-concurrency", + "-Xfrontend", "-enable-actor-data-race-checks", + ]) + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 00000000..57c498bd --- /dev/null +++ b/README.md @@ -0,0 +1,351 @@ +# swift-clocks + +⏰ A few clocks that make working with Swift concurrency more testable and more versatile. + +* [Motivation](#motivation) +* [Learn more](#learn-more) + * [`TestClock`](#testclock) + * [`ImmediateClock`](#immediateclock) + * [`UnimplementedClock`](#unimplementedclock) + * [Timers](#timers) +* [Documentation](#documentation) +* [License](#License) + +## Learn More + +This library was designed in episodes on [Point-Free][point-free], a video series exploring the +Swift programming language hosted by [Brandon Williams][mbrandonw] and +[Stephen Celis][stephencelis]. + +You can watch all of the episodes [here][clock-collection]. + + + video poster image + + +## Motivation + +The `Clock` protocol in Swift provides a powerful abstraction for time-based asynchrony in Swift's +structured concurrency. With just a single `sleep` method you can express many powerful async +operators, such as timers, `debounce`, `throttle`, `timeout` and more (see +[swift-async-algorithms][swift-async-algorithms]). + +However, the moment you use a concrete clock in your asynchronous code, or use `Task.sleep` +directly, you instantly lose the ability to easily test and preview your features, forcing you to +wait for real world time to pass to see how your feature works. + +This library provides new `Clock` conformances that allow you to turn any time-based asynchronous +code into something that is easier to test and debug: + +* [TestClock](#TestClock) +* [ImmediateClock](#ImmediateClock) +* [UnimplementedClock](#UnimplementedClock) +* [Timers](#Timers) + +### `TestClock` + +A clock whose time can be controlled in a deterministic manner. + +This clock is useful for testing how the flow of time affects asynchronous and concurrent code. +This includes any code that makes use of `sleep` or any time-based async operators, such as +`debounce`, `throttle`, `timeout`, and more. + +For example, suppose you have a model that encapsulates the behavior of a timer that be started and +stopped, and with each tick of the timer a count value was incremented: + +```swift +@MainActor +class FeatureModel: ObservableObject { + @Published var count = 0 + let clock: any Clock + var timerTask: Task? + + init(clock: any Clock) { + self.clock = clock + } + func startTimerButtonTapped() { + self.timerTask = Task { + while true { + try await self.clock.sleep(for: .seconds(1)) + self.count += 1 + } + } + } + func stopTimerButtonTapped() { + self.timerTask?.cancel() + self.timerTask = nil + } +} +``` + +Note that we have explicitly forced a clock to be provided in order to construct the `FeatureModel`. +This makes it possible to use a real life clock, such as `ContinuousClock`, when running on a device +or simulator, and use a more controllable clock in tests, such as the +[`TestClock`][test-clock-docs]. + +To write a test for this feature we can construct a `FeatureModel` with a `TestClock`, then advance +the clock forward and assert on how the model changes: + +```swift +func testTimer() async { + let clock = TestClock() + let model = FeatureModel(clock: clock) + + XCTAssertEqual(model.count, 0) + model.startTimerButtonTapped() + + // Advance the clock 1 second and prove that the model's + // count incremented by one. + await clock.advance(by: .seconds(1)) + XCTAssertEqual(model.count, 1) + + // Advance the clock 4 seconds and prove that the model's + // count incremented by 4. + await clock.advance(by: .seconds(4)) + XCTAssertEqual(model.count, 5) + + // Stop the timer, run the clock until there is no more + // suspensions, and prove that the count did not increment. + model.stopTimerButtonTapped() + await clock.run() + XCTAssertEqual(model.count, 5) +} +``` + +This test is easy to write, passes deterministically, and takes a fraction of a second to run. If +you were to use a concrete clock in your feature, such a test would be difficult to write. You +would have to wait for real time to pass, slowing down your test suite, and you would have to take +extra care to allow for the inherent imprecision in time-based asynchrony so that you do not have +flakey tests. + +### `ImmediateClock` + +A clock that does not suspend when sleeping. + +This clock is useful for squashing all of time down to a single instant, forcing any `sleep`s to +execute immediately. For example, suppose you have a feature that needs to wait 5 seconds before +performing some action, like showing a welcome message: + +```swift +struct Feature: View { + @State var message: String? + + var body: some View { + VStack { + if let message = self.message { + Text(self.message) + } + } + .task { + do { + try await Task.sleep(for: .seconds(5)) + self.message = "Welcome!" + } catch {} + } + } +} +``` + +This is currently using a real life clock by calling out to `Task.sleep`, which means every change +you make to the styling and behavior of this feature you must wait for 5 real life seconds to pass +before you see the effect. This will severely hurt you ability to quickly iterate on the feature in +an Xcode preview. + +The fix is to have your view hold onto a clock so that it can be controlled from the outside: + +```swift +struct Feature: View { + @State var message: String? + let clock: any Clock + + var body: some View { + VStack { + if let message = self.message { + Text(self.message) + } + } + .task { + do { + try await self.clock.sleep(for: .seconds(5)) + self.message = "Welcome!" + } catch {} + } + } +} +``` + +Then you can construct this view with a `ContinuousClock` when running on a device or simulator, +and use an ``ImmediateClock`` when running in an Xcode preview: + +```swift +struct Feature_Previews: PreviewProvider { + static var previews: some View { + Feature(clock: ImmediateClock()) + } +} +``` + +Now the welcome message will be displayed immediately with every change made to the view. No +need to wait for 5 real world seconds to pass. + +You can also propagate a clock to a SwiftUI view via the `continuousClock` and `suspendingClock` +environment values that ship with the library: + +```swift +struct Feature: View { + @State var message: String? + @Environment(\.continuousClock) var clock + + var body: some View { + VStack { + if let message = self.message { + Text(self.message) + } + } + .task { + do { + try await self.clock.sleep(for: .seconds(5)) + self.message = "Welcome!" + } catch {} + } + } +} + +struct Feature_Previews: PreviewProvider { + static var previews: some View { + Feature() + .environment(\.continuousClock, ImmediateClock()) + } +} +``` + +### `UnimplementedClock` + +A clock that causes an XCTest failure when any of its endpoints are invoked. + +This test is useful when a clock dependency must be provided to test a feature, but you don't +actually expect the clock to be used in the particular execution flow you are exercising. + +For example, consider the following model that encapsulates the behavior of being able to increment +and decrement a count, as well as starting and stopping a timer that increments the counter every +second: + +```swift +@MainActor +class FeatureModel: ObservableObject { + @Published var count = 0 + let clock: any Clock + var timerTask: Task? + + init(clock: any Clock) { + self.clock = clock + } + func incrementButtonTapped() { + self.count += 1 + } + func decrementButtonTapped() { + self.count -= 1 + } + func startTimerButtonTapped() { + self.timerTask = Task { + for await _ in self.clock.timer(interval: .seconds(1)) { + self.count += 1 + } + } + } + func stopTimerButtonTapped() { + self.timerTask?.cancel() + self.timerTask = nil + } +} +``` + +If we test the flow of the user incrementing and decrementing the count, there is no need for the +clock. We don't expect any time-based asynchrony to occur. To make this clear, we can use an +`UnimplementedClock`: + +```swift +func testIncrementDecrement() { + let model = FeatureModel(clock: UnimplementedClock()) + + XCTAssertEqual(model.count, 0) + self.model.incrementButtonTapped() + XCTAssertEqual(model.count, 1) + self.model.decrementButtonTapped() + XCTAssertEqual(model.count, 0) +} +``` + +If this test passes it definitively proves that the clock is not used at all in the user flow being +tested, making this test stronger. If in the future the increment and decrement endpoints start +making use of time-based asynchrony using the clock, we will be instantly notified by test failures. +This will help us find the tests that should be updated to assert on the new behavior in the +feature. + +### Timers + +All clocks now come with a method that allows you to create an `AsyncSequence`-based timer on an +interval specified by a duration. This allows you to handle timers with simple `for await` syntax, +such as this observable object that exposes the ability to start and stop a timer for incrementing a +value every second: + +```swift +@MainActor +class FeatureModel: ObservableObject { + @Published var count = 0 + let clock: any Clock + var timerTask: Task? + + init(clock: any Clock) { + self.clock = clock + } + func startTimerButtonTapped() { + self.timerTask = Task { + for await _ in self.clock.timer(interval: .seconds(1)) { + self.count += 1 + } + } + } + func stopTimerButtonTapped() { + self.timerTask?.cancel() + self.timerTask = nil + } +} +``` + +This feature can also be easily tested by making use of the `TestClock` discussed above: + +```swift +func testTimer() async { + let clock = TestClock() + let model = FeatureModel(clock: clock) + + XCTAssertEqual(model.count, 0) + model.startTimerButtonTapped() + + await clock.advance(by: .seconds(1)) + XCTAssertEqual(model.count, 1) + + await clock.advance(by: .seconds(4)) + XCTAssertEqual(model.count, 5) + + model.stopTimerButtonTapped() + await clock.run() +} +``` + +## Documentation + +The latest documentation for this library is available [here][clock-docs]. + +## License + +This library is released under the MIT license. See [LICENSE](LICENSE) for details. + +[swift-async-algorithms]: http://github.com/apple/swift-async-algorithms +[point-free]: https://www.pointfree.co +[mbrandonw]: https://github.com/mbrandonw +[stephencelis]: https://github.com/stephencelis +[clock-collection]: https://www.pointfree.co/collections/concurrency/clocks +[clock-docs]: http://pointfreeco.github.io/swift-clocks/main/documentation/clocks +[test-clock-docs]: todo diff --git a/Sources/Clocks/Documentation.docc/Clocks.md b/Sources/Clocks/Documentation.docc/Clocks.md new file mode 100644 index 00000000..fa14f9d0 --- /dev/null +++ b/Sources/Clocks/Documentation.docc/Clocks.md @@ -0,0 +1,28 @@ +# ``Clocks`` + +A few clocks that make working with Swift concurrency more testable and more versatile. + +## Overview + +The `Clock` protocol in provides a powerful abstraction for time-based asynchrony in Swift's +structured concurrency. With just a single `sleep` method you can express many powerful async +operators, such as timers, `debounce`, `throttle`, `timeout` and more (see +[swift-async-algorithms][swift-async-algorithms]). + +However, the moment you use a concrete clock in your asynchronous code, or use `Task.sleep` +directly, you instantly lose the ability to easily test and preview your features, forcing you to +wait for real world time to pass to see how your feature works. + +This library provides new `Clock` conformances (``TestClock``, ``ImmediateClock`` and +``UnimplementedClock``) that allow you to turn any time-based asynchronous code into something that +is easier to test and debug. + +## Topics + +### Implementations + +- ``ImmediateClock`` +- ``TestClock`` +- ``UnimplementedClock`` + +[swift-async-algorithms]: http://github.com/apple/swift-async-algorithms diff --git a/Sources/Clocks/Documentation.docc/ImmediateClock.md b/Sources/Clocks/Documentation.docc/ImmediateClock.md new file mode 100644 index 00000000..15497a8b --- /dev/null +++ b/Sources/Clocks/Documentation.docc/ImmediateClock.md @@ -0,0 +1,8 @@ +# ``Clocks/ImmediateClock`` + +## Topics + +### Creating immediate clocks + +- ``init(now:)`` +- ``init()`` diff --git a/Sources/Clocks/Documentation.docc/TestClock.md b/Sources/Clocks/Documentation.docc/TestClock.md new file mode 100644 index 00000000..0fb01bb9 --- /dev/null +++ b/Sources/Clocks/Documentation.docc/TestClock.md @@ -0,0 +1,19 @@ +# ``Clocks/TestClock`` + +## Topics + +### Creating test clocks + +- ``init(now:)`` +- ``init()`` + +### Controlling time + +- ``advance(by:)`` +- ``advance(to:)`` +- ``run(timeout:file:line:)`` + +### Checking for suspension + +- ``checkSuspension()`` +- ``SuspensionError`` diff --git a/Sources/Clocks/Documentation.docc/UnimplementedClock.md b/Sources/Clocks/Documentation.docc/UnimplementedClock.md new file mode 100644 index 00000000..beb91639 --- /dev/null +++ b/Sources/Clocks/Documentation.docc/UnimplementedClock.md @@ -0,0 +1,8 @@ +# ``Clocks/UnimplementedClock`` + +## Topics + +### Creating unimplemented clocks + +- ``init(name:now:)`` +- ``init(name:)`` diff --git a/Sources/Clocks/ImmediateClock.swift b/Sources/Clocks/ImmediateClock.swift new file mode 100644 index 00000000..7b2fceac --- /dev/null +++ b/Sources/Clocks/ImmediateClock.swift @@ -0,0 +1,178 @@ +#if canImport(RoomPlan) || (!canImport(Darwin) && swift(>=5.7)) + import Foundation + + /// A clock that does not suspend when sleeping. + /// + /// This clock is useful for squashing all of time down to a single instant, forcing any `sleep`s + /// to execute immediately. + /// + /// For example, suppose you have a feature that needs to wait 5 seconds before performing some + /// action, like showing a welcome message: + /// + /// ```swift + /// struct Feature: View { + /// @State var message: String? + /// + /// var body: some View { + /// VStack { + /// if let message = self.message { + /// Text(self.message) + /// .font(.largeTitle.bold()) + /// .foregroundColor(.mint) + /// } + /// } + /// .task { + /// do { + /// try await Task.sleep(for: .seconds(5)) + /// self.message = "Welcome!" + /// } catch {} + /// } + /// } + /// } + /// ``` + /// + /// This is currently using a real life clock by calling out to `Task.sleep(for:)`, which means + /// every change you make to the styling and behavior of this feature you must wait for 5 real life + /// seconds to pass before you see the affect. This will severely hurt you ability to quickly + /// iterate on the feature in an Xcode preview. + /// + /// The fix is to have your view hold onto a clock so that it can be controlled from the outside: + /// + /// ```swift + /// struct Feature: View { + /// @State var message: String? + /// let clock: any Clock + /// + /// var body: some View { + /// VStack { + /// if let message = self.message { + /// Text(self.message) + /// .font(.largeTitle.bold()) + /// .foregroundColor(.mint) + /// } + /// } + /// .task { + /// do { + /// try await self.clock.sleep(for: .seconds(5)) + /// self.message = "Welcome!" + /// } catch {} + /// } + /// } + /// } + /// ``` + /// + /// This code is nearly the same as before except that it now holds onto an explicit clock. This + /// allows you to use a `ContinuousClock` when running on a device or simulator, and use an + /// ``ImmediateClock`` when running in an Xcode preview: + /// + /// ```swift + /// struct Feature_Previews: PreviewProvider { + /// static var previews: some View { + /// Feature(clock: .immediate) + /// } + /// } + /// ``` + /// + /// Now the welcome message will be displayed immediately with every change made to the view. No + /// need to wait for 5 real world seconds to pass, making it easier to iterate on the feature. + /// + /// You can also propagate a clock to a SwiftUI view via the `continuousClock` and `suspendingClock` + /// environment values that ship with the library: + /// + /// ```swift + /// struct Feature: View { + /// @State var message: String? + /// @Environment(\.continuousClock) var clock + /// + /// var body: some View { + /// VStack { + /// if let message = self.message { + /// Text(self.message) + /// } + /// } + /// .task { + /// do { + /// try await self.clock.sleep(for: .seconds(5)) + /// self.message = "Welcome!" + /// } catch {} + /// } + /// } + /// } + /// + /// struct Feature_Previews: PreviewProvider { + /// static var previews: some View { + /// Feature() + /// .environment(\.continuousClock, .immediate) + /// } + /// } + /// ``` + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public final class ImmediateClock: Clock, @unchecked Sendable + where + Duration: DurationProtocol, + Duration: Hashable + { + public struct Instant: InstantProtocol { + public var offset: Duration + public init(offset: Duration = .zero) { + self.offset = offset + } + + public func advanced(by duration: Duration) -> Self { + .init(offset: self.offset + duration) + } + + public func duration(to other: Self) -> Duration { + other.offset - self.offset + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.offset < rhs.offset + } + } + + public var now = Instant() + public var minimumResolution = Instant.Duration.zero + private let lock = NSLock() + + public init(now: Instant = .init()) { + self.now = now + } + + public func sleep(until deadline: Instant, tolerance: Instant.Duration?) async throws { + try Task.checkCancellation() + self.lock.sync { self.now = deadline } + await Task.megaYield() + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension ImmediateClock where Duration == Swift.Duration { + public convenience init() { + self.init(now: .init()) + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension Clock where Self == ImmediateClock { + /// A clock that does not suspend when sleeping. + /// + /// Constructs and returns an ``ImmediateClock`` + /// + /// > Important: Due to [a bug in Swift](https://github.com/apple/swift/issues/61645), this static + /// > value cannot be used in an existential context: + /// > + /// > ```swift + /// > let clock: any Clock = .immediate // 🛑 + /// > ``` + /// > + /// > To work around this bug, construct an immediate clock directly: + /// > + /// > ```swift + /// > let clock: any Clock = ImmediateClock() // ✅ + /// > ``` + public static var immediate: Self { + ImmediateClock() + } + } +#endif diff --git a/Sources/Clocks/Internal/Lock.swift b/Sources/Clocks/Internal/Lock.swift new file mode 100644 index 00000000..de7c025b --- /dev/null +++ b/Sources/Clocks/Internal/Lock.swift @@ -0,0 +1,21 @@ +import Foundation + +extension NSLock { + @inlinable + @discardableResult + func sync(operation: () -> R) -> R { + self.lock() + defer { self.unlock() } + return operation() + } +} + +extension NSRecursiveLock { + @inlinable + @discardableResult + func sync(operation: () -> R) -> R { + self.lock() + defer { self.unlock() } + return operation() + } +} diff --git a/Sources/Clocks/Internal/Yield.swift b/Sources/Clocks/Internal/Yield.swift new file mode 100644 index 00000000..a4f32821 --- /dev/null +++ b/Sources/Clocks/Internal/Yield.swift @@ -0,0 +1,10 @@ +extension Task where Success == Failure, Failure == Never { + // NB: We would love if this was not necessary, but due to a lack of async testing tools in Swift + // we're not sure if there is an alternative. See this forum post for more information: + // https://forums.swift.org/t/reliably-testing-code-that-adopts-swift-concurrency/57304 + static func megaYield(count: Int = 10) async { + for _ in 1...count { + await Task.detached(priority: .background) { await Task.yield() }.value + } + } +} diff --git a/Sources/Clocks/Internal/_AnyClock.swift b/Sources/Clocks/Internal/_AnyClock.swift new file mode 100644 index 00000000..c560db00 --- /dev/null +++ b/Sources/Clocks/Internal/_AnyClock.swift @@ -0,0 +1,44 @@ +#if canImport(RoomPlan) || (!canImport(Darwin) && swift(>=5.7)) + /// Internal use only. Not meant to be used outside the library. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public struct _AnyClock: Clock { + public struct Instant: InstantProtocol { + fileprivate var offset: Duration + + public func advanced(by duration: Duration) -> Self { + .init(offset: self.offset + duration) + } + + public func duration(to other: Self) -> Duration { + other.offset - self.offset + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.offset < rhs.offset + } + } + + private var _minimumResolution: @Sendable () -> Duration + private var _now: @Sendable () -> Instant + private var _sleep: @Sendable (Instant, Duration?) async throws -> Void + + public init(_ clock: C) where C.Instant.Duration == Duration { + let start = clock.now + self._now = { Instant(offset: start.duration(to: clock.now)) } + self._minimumResolution = { clock.minimumResolution } + self._sleep = { try await clock.sleep(until: start.advanced(by: $0.offset), tolerance: $1) } + } + + public var minimumResolution: Instant.Duration { + self._minimumResolution() + } + + public var now: Instant { + self._now() + } + + public func sleep(until deadline: Instant, tolerance: Instant.Duration? = nil) async throws { + try await self._sleep(deadline, tolerance) + } + } +#endif diff --git a/Sources/Clocks/Internal/_AsyncTimerSequence.swift b/Sources/Clocks/Internal/_AsyncTimerSequence.swift new file mode 100644 index 00000000..b2c30c45 --- /dev/null +++ b/Sources/Clocks/Internal/_AsyncTimerSequence.swift @@ -0,0 +1,83 @@ +#if canImport(RoomPlan) || (!canImport(Darwin) && swift(>=5.7)) + //===----------------------------------------------------------------------===// + // + // This source file is part of the Swift Async Algorithms open source project + // + // Copyright (c) 2022 Apple Inc. and the Swift project authors + // Licensed under Apache License v2.0 with Runtime Library Exception + // + // See https://swift.org/LICENSE.txt for license information + // + //===----------------------------------------------------------------------===// + + /// An `AsyncSequence` that produces elements at regular intervals. + /// + /// Internal use only. Not meant to be used outside the library. + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + public struct _AsyncTimerSequence: AsyncSequence { + public typealias Element = C.Instant + + /// The iterator for an `AsyncTimerSequence` instance. + public struct Iterator: AsyncIteratorProtocol { + var clock: C? + let interval: C.Instant.Duration + let tolerance: C.Instant.Duration? + var last: C.Instant? + + init(interval: C.Instant.Duration, tolerance: C.Instant.Duration?, clock: C) { + self.clock = clock + self.interval = interval + self.tolerance = tolerance + } + + func nextDeadline(_ clock: C) -> C.Instant { + let now = clock.now + let last = self.last ?? now + let next = last.advanced(by: interval) + if next < now { + return last.advanced( + by: interval * Int(((next.duration(to: now)) / interval).rounded(.up))) + } else { + return next + } + } + + public mutating func next() async -> C.Instant? { + guard let clock = clock else { + return nil + } + let next = nextDeadline(clock) + do { + try await clock.sleep(until: next, tolerance: tolerance) + } catch { + self.clock = nil + return nil + } + let now = clock.now + last = next + return now + } + } + + let clock: C + let interval: C.Instant.Duration + let tolerance: C.Instant.Duration? + + /// Create an `AsyncTimerSequence` with a given repeating interval. + init(interval: C.Instant.Duration, tolerance: C.Instant.Duration? = nil, clock: C) { + self.clock = clock + self.interval = interval + self.tolerance = tolerance + } + + public func makeAsyncIterator() -> Iterator { + Iterator(interval: interval, tolerance: tolerance, clock: clock) + } + } + + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + extension _AsyncTimerSequence: Sendable {} + + @available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) + extension _AsyncTimerSequence.Iterator: Sendable {} +#endif diff --git a/Sources/Clocks/Shims.swift b/Sources/Clocks/Shims.swift new file mode 100644 index 00000000..fb7d8ff0 --- /dev/null +++ b/Sources/Clocks/Shims.swift @@ -0,0 +1,17 @@ +#if canImport(RoomPlan) || (!canImport(Darwin) && swift(>=5.7)) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension Clock { + /// Suspends for the given duration. + /// + /// This method should be provided by the standard library, but it is not yet included. See this + /// proposal for more information: + /// + @_disfavoredOverload + public func sleep( + for duration: Duration, + tolerance: Duration? = nil + ) async throws { + try await self.sleep(until: self.now.advanced(by: duration), tolerance: tolerance) + } + } +#endif diff --git a/Sources/Clocks/SwiftUI.swift b/Sources/Clocks/SwiftUI.swift new file mode 100644 index 00000000..a8b97b79 --- /dev/null +++ b/Sources/Clocks/SwiftUI.swift @@ -0,0 +1,26 @@ +#if canImport(RoomPlan) || (!canImport(Darwin) && swift(>=5.7)) + import SwiftUI + + @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) + extension EnvironmentValues { + public var continuousClock: any Clock { + get { self[ContinuousClockKey.self] } + set { self[ContinuousClockKey.self] = newValue } + } + + public var suspendingClock: any Clock { + get { self[SuspendingClockKey.self] } + set { self[SuspendingClockKey.self] = newValue } + } + } + + @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) + private enum ContinuousClockKey: EnvironmentKey { + static let defaultValue: any Clock = ContinuousClock() + } + + @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) + private enum SuspendingClockKey: EnvironmentKey { + static let defaultValue: any Clock = SuspendingClock() + } +#endif diff --git a/Sources/Clocks/TestClock.swift b/Sources/Clocks/TestClock.swift new file mode 100644 index 00000000..831987b6 --- /dev/null +++ b/Sources/Clocks/TestClock.swift @@ -0,0 +1,270 @@ +#if canImport(RoomPlan) || (!canImport(Darwin) && swift(>=5.7)) + import Foundation + import XCTestDynamicOverlay + + /// A clock whose time can be controlled in a deterministic manner. + /// + /// This clock is useful for testing how the flow of time affects asynchronous and concurrent code. + /// This includes any code that makes use of `sleep` or any time-based async operators, such as + /// timers, `debounce`, `throttle`, `timeout`, and more. + /// + /// For example, suppose you have a model that encapsulates the behavior of a timer that can be + /// started and stopped: + /// + /// ```swift + /// @MainActor + /// class FeatureModel: ObservableObject { + /// @Published var count = 0 + /// let clock: any Clock + /// var timerTask: Task? + /// + /// init(clock: any Clock) { + /// self.clock = clock + /// } + /// func startTimerButtonTapped() { + /// self.timerTask = Task { + /// while true { + /// try await self.clock.sleep(for: .seconds(5)) + /// self.count += 1 + /// } + /// } + /// } + /// func stopTimerButtonTapped() { + /// self.timerTask?.cancel() + /// self.timerTask = nil + /// } + /// } + /// ``` + /// + /// Here we have explicitly forced a clock to be provided in order to construct the `FeatureModel`. + /// This makes it possible to use a real life clock, such as `ContinuousClock`, when running on a + /// device or simulator, and use a more controllable clock in tests, such as the ``TestClock``. + /// + /// To write a test for this feature we can construct a `FeatureModel` with a ``TestClock``, then + /// advance the clock forward and assert on how the model changes: + /// + /// ```swift + /// func testTimer() async { + /// let clock = TestClock() + /// let model = FeatureModel(clock: clock) + /// + /// XCTAssertEqual(model.count, 0) + /// model.startTimerButtonTapped() + /// + /// await clock.advance(by: .seconds(1)) + /// XCTAssertEqual(model.count, 1) + /// + /// await clock.advance(by: .seconds(4)) + /// XCTAssertEqual(model.count, 5) + /// + /// model.stopTimerButtonTapped() + /// await clock.run() + /// } + /// ``` + /// + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public final class TestClock: Clock, @unchecked Sendable { + public struct Instant: InstantProtocol { + public var offset: Duration + + public init(offset: Duration = .zero) { + self.offset = offset + } + + public func advanced(by duration: Duration) -> Self { + .init(offset: self.offset + duration) + } + + public func duration(to other: Self) -> Duration { + other.offset - self.offset + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.offset < rhs.offset + } + } + + public var minimumResolution: Duration = .zero + public private(set) var now: Instant + + private let lock = NSRecursiveLock() + private var suspensions: + [(id: UUID, deadline: Instant, continuation: AsyncStream.Continuation)] = [] + + public init(now: Instant = .init()) { + self.now = .init() + } + + public func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { + try Task.checkCancellation() + let id = UUID() + do { + let stream: AsyncStream? = self.lock.sync { + guard deadline >= self.now + else { + return nil + } + return AsyncStream { continuation in + self.suspensions.append((id: id, deadline: deadline, continuation: continuation)) + } + } + guard let stream = stream + else { return } + for await _ in stream {} + try Task.checkCancellation() + } catch is CancellationError { + self.lock.sync { self.suspensions.removeAll(where: { $0.id == id }) } + throw CancellationError() + } catch { + throw error + } + } + + /// Throws an error if there are active sleeps on the clock. + /// + /// This can be useful for proving that your feature will not perform any more time-based + /// asynchrony. For example, the following will throw because the clock has an active suspension + /// scheduled: + /// + /// ```swift + /// let clock = TestClock() + /// Task { + /// try await clock.sleep(for: .seconds(1)) + /// } + /// try await clock.checkSuspension() + /// ``` + /// + /// However, the following will not throw because advancing the clock has finished the suspension: + /// + /// ```swift + /// let clock = TestClock() + /// Task { + /// try await clock.sleep(for: .seconds(1)) + /// } + /// await clock.advance(for: .seconds(1)) + /// try await clock.checkSuspension() + /// ``` + public func checkSuspension() async throws { + await Task.megaYield() + guard self.lock.sync(operation: { self.suspensions.isEmpty }) + else { throw SuspensionError() } + } + + /// Advances the test clock's internal time by the duration. + /// + /// See the documentation for ``TestClock`` to see how to use this method. + public func advance(by duration: Duration = .zero) async { + await self.advance(to: self.lock.sync(operation: { self.now.advanced(by: duration) })) + } + + /// Advances the test clock's internal time to the deadline. + /// + /// See the documentation for ``TestClock`` to see how to use this method. + public func advance(to deadline: Instant) async { + while self.lock.sync(operation: { self.now <= deadline }) { + await Task.megaYield() + let `return` = { + self.lock.lock() + self.suspensions.sort { $0.deadline < $1.deadline } + + guard + let next = self.suspensions.first, + deadline >= next.deadline + else { + self.now = deadline + self.lock.unlock() + return true + } + + self.now = next.deadline + self.suspensions.removeFirst() + self.lock.unlock() + next.continuation.finish() + return false + }() + + if `return` { + return + } + } + } + + /// Runs the clock until it has no scheduled sleeps left. + /// + /// This method is useful for letting a clock run to its end without having to explicitly account + /// for each sleep. For example, suppose you have a feature that runs a timer for 10 ticks, and + /// each tick it increments a counter. If you don't want to worry about advancing the timer for + /// each tick, you can instead just `run` the clock out: + /// + /// ```swift + /// func testTimer() async { + /// let clock = TestClock() + /// let model = FeatureModel(clock: clock) + /// + /// XCTAssertEqual(model.count, 0) + /// model.startTimerButtonTapped() + /// + /// await clock.run() + /// XCTAssertEqual(model.count, 10) + /// } + /// ``` + /// + /// It is possible to run a clock that never finishes, hence causing a suspension that never + /// finishes. This can happen if you create an unbounded timer. In order to prevent holding up + /// your test suite forever, the ``run(timeout:file:line:)`` method will terminate and cause a + /// test failure if a timeout duration is reached. + /// + /// - Parameters: + /// - duration: The amount of time to allow for all work on the clock to finish. + public func run( + timeout duration: Swift.Duration = .milliseconds(500), + file: StaticString = #file, + line: UInt = #line + ) async { + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await Task.sleep(until: .now.advanced(by: duration), clock: .continuous) + throw CancellationError() + } + group.addTask { + await Task.megaYield() + while let deadline = self.lock.sync(operation: { self.suspensions.first?.deadline }) { + try Task.checkCancellation() + await self.advance(by: self.lock.sync(operation: { self.now.duration(to: deadline) })) + } + } + try await group.next() + group.cancelAll() + } + } catch { + XCTFail( + """ + Expected all sleeps to finish, but some are still suspending after \(duration). + + There are sleeps suspending. This could mean you are not advancing the test clock far \ + enough for your feature to execute its logic, or there could be a bug in your feature's \ + logic. + + You can also increase the timeout of 'run' to be greater than \(duration). + """, + file: file, + line: line + ) + } + } + } + + /// An error that indicates there are actively suspending sleeps scheduled on the clock. + /// + /// This error is thrown automatically by ``TestClock/checkSuspension()`` if there are actively + /// suspending sleeps scheduled on the clock. + public struct SuspensionError: Error {} + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension TestClock where Duration == Swift.Duration { + public convenience init() { + self.init(now: .init()) + } + } +#endif diff --git a/Sources/Clocks/Timer.swift b/Sources/Clocks/Timer.swift new file mode 100644 index 00000000..35a71c3c --- /dev/null +++ b/Sources/Clocks/Timer.swift @@ -0,0 +1,12 @@ +#if canImport(RoomPlan) || (!canImport(Darwin) && swift(>=5.7)) + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension Clock where Duration: Hashable { + /// Creates an async sequence that emits the clock's `now` value on an interval. + public func timer( + interval: Self.Duration, + tolerance: Self.Duration? = nil + ) -> _AsyncTimerSequence<_AnyClock> { + .init(interval: interval, tolerance: tolerance, clock: _AnyClock(self)) + } + } +#endif diff --git a/Sources/Clocks/UnimplementedClock.swift b/Sources/Clocks/UnimplementedClock.swift new file mode 100644 index 00000000..554f05a1 --- /dev/null +++ b/Sources/Clocks/UnimplementedClock.swift @@ -0,0 +1,152 @@ +#if canImport(RoomPlan) || (!canImport(Darwin) && swift(>=5.7)) + import Foundation + import XCTestDynamicOverlay + + /// A clock that causes an XCTest failure when any of its endpoints are invoked. + /// + /// This test is useful when a clock dependency must be provided to test a feature, but you don't + /// actually expect time-based asynchrony to occur in the particular execution flow you are + /// exercising. + /// + /// For example, consider the following model that encapsulates the behavior of being able to + /// increment and decrement a count, as well as starting and stopping a timer that increments + /// the counter every second: + /// + /// ```swift + /// @MainActor + /// class FeatureModel: ObservableObject { + /// @Published var count = 0 + /// let clock: any Clock + /// var timerTask: Task? + /// + /// init(clock: any Clock) { + /// self.clock = clock + /// } + /// func incrementButtonTapped() { + /// self.count += 1 + /// } + /// func decrementButtonTapped() { + /// self.count -= 1 + /// } + /// func startTimerButtonTapped() { + /// self.timerTask = Task { + /// for await _ in self.clock.timer(interval: .seconds(5)) { + /// self.count += 1 + /// } + /// } + /// } + /// func stopTimerButtonTapped() { + /// self.timerTask?.cancel() + /// self.timerTask = nil + /// } + /// } + /// ``` + /// + /// If we test the flow of the user incrementing and decrementing the count, there is no need for + /// the clock. We don't expect any time-based asynchrony to occur. To make this clear, we can + /// use an ``UnimplementedClock``: + /// + /// ```swift + /// func testIncrementDecrement() { + /// let model = FeatureModel(clock: .unimplemented) + /// + /// XCTAssertEqual(model.count, 0) + /// self.model.incrementButtonTapped() + /// XCTAssertEqual(model.count, 1) + /// self.model.decrementButtonTapped() + /// XCTAssertEqual(model.count, 0) + /// } + /// ``` + /// + /// If this test passes it definitively proves that the clock is not used at all in the user flow + /// being tested, making this test stronger. If in the future the increment and decrement endpoints + /// start making use of time-based asynchrony using the clock, we will be instantly notified by test + /// failures. This will help us find the tests that should be updated to assert on the new behavior + /// in the feature. + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + public final class UnimplementedClock: Clock, @unchecked Sendable + where + Duration: DurationProtocol, + Duration: Hashable + { + public struct Instant: InstantProtocol { + public var offset: Duration + public init(offset: Duration = .zero) { + self.offset = offset + } + + public func advanced(by duration: Duration) -> Self { + .init(offset: self.offset + duration) + } + + public func duration(to other: Self) -> Duration { + other.offset - self.offset + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + lhs.offset < rhs.offset + } + } + + public var now: Instant { + XCTFail("Unimplemented: \(self.name).now") + return self._now + } + public var _now = Instant() + public var minimumResolution: Duration { + XCTFail("Unimplemented: \(self.name).minimumResolution") + return .zero + } + /// The name of the clock. + /// + /// Printed to identify the clock in failure messages. + public let name: String + + private let lock = NSRecursiveLock() + + public init( + name: String = "Clock", + now: Instant = .init() + ) { + self.name = name + self._now = now + } + + public func sleep(until deadline: Instant, tolerance: Instant.Duration? = nil) async throws { + XCTFail("Unimplemented: \(self.name).sleep") + try Task.checkCancellation() + self.lock.sync { self._now = deadline } + await Task.megaYield() + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension UnimplementedClock where Duration == Swift.Duration { + public convenience init(name: String = "Clock") { + self.init(name: name, now: .init()) + } + } + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + extension Clock where Self == UnimplementedClock { + /// A clock that causes an XCTest failure when any of its endpoints are invoked. + /// + /// Constructs and returns an ``UnimplementedClock`` + /// + /// > Important: Due to [a bug in Swift](https://github.com/apple/swift/issues/61645), this static + /// > value cannot be used in an existential context: + /// > + /// > ```swift + /// > let clock: any Clock = .unimplemented // 🛑 + /// > ``` + /// > + /// > To work around this bug, construct an unimplemented clock directly: + /// > + /// > ```swift + /// > let clock: any Clock = UnimplementedClock() // ✅ + /// > ``` + public static var unimplemented: Self { + UnimplementedClock() + } + } +#endif diff --git a/Tests/ClocksTests/AsyncAlgorithmsTests.swift b/Tests/ClocksTests/AsyncAlgorithmsTests.swift new file mode 100644 index 00000000..221e536c --- /dev/null +++ b/Tests/ClocksTests/AsyncAlgorithmsTests.swift @@ -0,0 +1,166 @@ +import AsyncAlgorithms +import Clocks +import XCTest + +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) +@MainActor +final class AsyncAlgorithmsTests: XCTestCase, @unchecked Sendable { + let clock = TestClock() + + override func tearDown() async throws { + try await super.tearDown() + try await self.clock.checkSuspension() + } + + func testTimer() async { + let timer = AsyncTimerSequence(interval: .seconds(1), clock: self.clock) + .prefix(10) + + let ticks = ActorIsolated(0) + let task = Task { + for await _ in timer { + await ticks.withValue { $0 += 1 } + } + } + + await self.clock.advance(by: .seconds(1)) + var actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 1) + + await self.clock.advance(by: .seconds(4)) + actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 5) + + await self.clock.advance(by: .seconds(5)) + actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 10) + + await self.clock.run() + await task.value + XCTAssertEqual(actualTicks, 10) + } + + func testDebounce() async throws { + let (stream, continuation) = AsyncStream.streamWithContinuation() + + let ticks = ActorIsolated(0) + let task = Task { + for await _ in stream.debounce(for: .seconds(1), clock: self.clock) { + await ticks.withValue { $0 += 1 } + } + } + + // Nothing is emitted immediately after the base stream emits. + continuation.yield() + await self.clock.advance() + var actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 0) + + // Nothing is emitted after half a second. + await self.clock.advance(by: .milliseconds(500)) + actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 0) + + // Ping the base stream again. + continuation.yield() + + // Nothing is emitted after another half a second. + await self.clock.advance(by: .milliseconds(500)) + actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 0) + + // Only after waiting a full second after the base emitted do we get an emission. + await self.clock.advance(by: .milliseconds(500)) + actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 1) + + // Pending emission is discarded if base stream finishes. + continuation.yield() + continuation.finish() + await self.clock.run() + await task.value + XCTAssertEqual(actualTicks, 1) + } + + func testThrottle() async throws { + let (stream, continuation) = AsyncStream.streamWithContinuation() + + let ticks = ActorIsolated(0) + let task = Task { + for await _ in stream.throttle(for: .seconds(1), clock: self.clock) { + await ticks.withValue { $0 += 1 } + } + } + + // First base stream value is emitted immediately. + continuation.yield() + await self.clock.advance() + var actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 1) + + // Ping the base stream after half a second. + await self.clock.advance(by: .milliseconds(500)) + continuation.yield() + + // Nothing is emitted after another half a second. + await self.clock.advance() + actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 1) + + // Ping the base stream after another half a second. + await self.clock.advance(by: .milliseconds(500)) + continuation.yield() + + // Value is emitted + await self.clock.advance() + actualTicks = await ticks.value + XCTAssertEqual(actualTicks, 2) + + // Pending emission is discarded if base stream finishes. + continuation.yield() + continuation.finish() + await self.clock.run() + await task.value + XCTAssertEqual(actualTicks, 2) + } + + func testSelect_First() async throws { + let task = Task { + await Task.select([ + Task { + try await self.clock.sleep(for: .seconds(1)) + return 1 + }, + Task { + try await self.clock.sleep(for: .seconds(2)) + return 2 + }, + ]) + } + + await self.clock.advance(by: .seconds(2)) + + let winner = try await task.value.value + XCTAssertEqual(winner, 1) + } + + func testSelect_Second() async throws { + let task = Task { + await Task.select([ + Task { + try await self.clock.sleep(for: .seconds(2)) + return 1 + }, + Task { + try await self.clock.sleep(for: .seconds(1)) + return 2 + }, + ]) + } + + await self.clock.advance(by: .seconds(2)) + + let winner = try await task.value.value + XCTAssertEqual(winner, 2) + } +} diff --git a/Tests/ClocksTests/ImmediateClockTests.swift b/Tests/ClocksTests/ImmediateClockTests.swift new file mode 100644 index 00000000..bd34d404 --- /dev/null +++ b/Tests/ClocksTests/ImmediateClockTests.swift @@ -0,0 +1,43 @@ +import Clocks +import XCTest + +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) +@MainActor +final class ImmediateClockTests: XCTestCase { + func testTimer() async throws { + let clock = ImmediateClock() + + let tasks = Task { + var ticks = 0 + while ticks < 10 { + try await clock.sleep(for: .seconds(1)) + ticks += 1 + } + return ticks + } + + let ticks = try await tasks.value + XCTAssertEqual(ticks, 10) + XCTAssertEqual(clock.now, ImmediateClock.Instant().advanced(by: .seconds(10))) + } + + func testNow() async throws { + let clock = ImmediateClock() + try await clock.sleep(for: .seconds(5)) + XCTAssertEqual(clock.now.offset, .seconds(5)) + } + + func testCooperativeCancellation() async throws { + let clock = ImmediateClock() + let task = Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) + try await clock.sleep(for: .seconds(1)) + } + task.cancel() + + do { + try await task.value + XCTFail("Task should have thrown an error") + } catch {} + } +} diff --git a/Tests/ClocksTests/Internal/ActorIsolated.swift b/Tests/ClocksTests/Internal/ActorIsolated.swift new file mode 100644 index 00000000..cab0d1b0 --- /dev/null +++ b/Tests/ClocksTests/Internal/ActorIsolated.swift @@ -0,0 +1,24 @@ +@dynamicMemberLookup +final actor ActorIsolated { + var value: Value + + init(_ value: Value) { + self.value = value + } + + subscript(dynamicMember keyPath: KeyPath) -> Subject { + self.value[keyPath: keyPath] + } + + func withValue( + _ operation: @Sendable (inout Value) async throws -> T + ) async rethrows -> T { + var value = self.value + defer { self.value = value } + return try await operation(&value) + } + + func setValue(_ newValue: Value) { + self.value = newValue + } +} diff --git a/Tests/ClocksTests/Internal/AsyncStream.swift b/Tests/ClocksTests/Internal/AsyncStream.swift new file mode 100644 index 00000000..ffc998c9 --- /dev/null +++ b/Tests/ClocksTests/Internal/AsyncStream.swift @@ -0,0 +1,9 @@ +extension AsyncStream { + static func streamWithContinuation( + _ elementType: Element.Type = Element.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: Self, continuation: Continuation) { + var continuation: Continuation! + return (Self(elementType, bufferingPolicy: limit) { continuation = $0 }, continuation) + } +} diff --git a/Tests/ClocksTests/ShimTests.swift b/Tests/ClocksTests/ShimTests.swift new file mode 100644 index 00000000..0e46431d --- /dev/null +++ b/Tests/ClocksTests/ShimTests.swift @@ -0,0 +1,24 @@ +import Clocks +import XCTest + +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) +@MainActor +final class ShimTests: XCTestCase { + func testClockSleepFor() async { + let testClock = TestClock() + let clock: some Clock = testClock + + let isFinished = ActorIsolated(false) + Task { + try await clock.sleep(for: .seconds(1)) + await isFinished.setValue(true) + } + + var checkIsFinished = await isFinished.value + XCTAssertEqual(checkIsFinished, false) + + await testClock.advance(by: .seconds(1)) + checkIsFinished = await isFinished.value + XCTAssertEqual(checkIsFinished, true) + } +} diff --git a/Tests/ClocksTests/TestClocksTests.swift b/Tests/ClocksTests/TestClocksTests.swift new file mode 100644 index 00000000..c81a9061 --- /dev/null +++ b/Tests/ClocksTests/TestClocksTests.swift @@ -0,0 +1,194 @@ +import AsyncAlgorithms +import Clocks +import XCTest + +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) +@MainActor +final class TestClockTests: XCTestCase, @unchecked Sendable { + let clock = TestClock() + + override func tearDown() async throws { + try await super.tearDown() + try await self.clock.checkSuspension() + } + + func testAdvance() async { + let isFinished = ActorIsolated(false) + Task { + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + await isFinished.setValue(true) + } + + var checkIsFinished = await isFinished.value + XCTAssertEqual(checkIsFinished, false) + + await self.clock.advance(by: .seconds(1)) + checkIsFinished = await isFinished.value + XCTAssertEqual(checkIsFinished, true) + } + + func testAdvanceWithReentrantUnitsOfWork() async throws { + let task = Task { + var count = 0 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + return count + } + + await self.clock.advance(by: .seconds(5)) + let ticks = try await task.value + XCTAssertEqual(ticks, 5) + } + + func testRun() async { + let isFinished = ActorIsolated(false) + Task { + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + await isFinished.setValue(true) + } + + var checkIsFinished = await isFinished.value + XCTAssertEqual(checkIsFinished, false) + + await self.clock.run() + checkIsFinished = await isFinished.value + XCTAssertEqual(checkIsFinished, true) + } + + #if DEBUG + func testRunWithTimeout() async throws { + XCTExpectFailure { + $0.compactDescription == """ + Expected all sleeps to finish, but some are still suspending after 1.0 seconds. + + There are sleeps suspending. This could mean you are not advancing the test clock far \ + enough for your feature to execute its logic, or there could be a bug in your feature's \ + logic. + + You can also increase the timeout of 'run' to be greater than 1.0 seconds. + """ + } + + let (stream, continuation) = AsyncStream.streamWithContinuation() + let isRunning = ActorIsolated(true) + Task { + continuation.finish() + while await isRunning.value { + try await self.clock.sleep(for: .seconds(1)) + } + } + for await _ in stream {} + await self.clock.run(timeout: .seconds(1)) + await isRunning.setValue(false) + await self.clock.run(timeout: .seconds(1)) + } + #endif + + func testRunMultipleUnitsOfWork() async { + let timer = AsyncTimerSequence(interval: .seconds(1), clock: self.clock) + .prefix(10) + + let task = Task { + var ticks = 0 + for await _ in timer { + ticks += 1 + } + return ticks + } + + await self.clock.run(timeout: .seconds(1)) + let ticks = await task.value + XCTAssertEqual(ticks, 10) + } + + func testRunWithReentrantUnitsOfWork() async throws { + let task = Task { + var count = 0 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + try await self.clock.sleep(until: self.clock.now.advanced(by: .seconds(1))) + count += 1 + return count + } + + await self.clock.run() + let ticks = try await task.value + XCTAssertEqual(ticks, 5) + } + + func testCheckScheduledWork() async throws { + Task { try await self.clock.sleep(for: .seconds(1)) } + + let didThrow: Bool + do { + try await self.clock.checkSuspension() + XCTFail() + return + } catch is SuspensionError { + didThrow = true + } catch { + XCTFail() + return + } + + XCTAssertEqual(didThrow, true) + await self.clock.advance(by: .seconds(1)) + try await self.clock.checkSuspension() + } + + func testCooperativeCancellation() async { + actor DidFinish { + var value = false + func finish() { self.value = true } + } + let didFinish = DidFinish() + let task = Task { + try await self.clock.sleep(for: .seconds(1)) + await didFinish.finish() + } + + task.cancel() + await self.clock.run() + + let actualDidFinish = await didFinish.value + XCTAssertEqual(actualDidFinish, false) + } + + func testCancellationRemovesScheduledItem() async throws { + let (stream, continuation) = AsyncStream.streamWithContinuation() + + let task = Task { + continuation.finish() + try await self.clock.sleep(for: .seconds(1)) + } + + for await _ in stream {} + await Task.yield() + task.cancel() + + try await self.clock.checkSuspension() + } + + func testNow() async throws { + let task = Task { + try await self.clock.sleep(for: .seconds(5)) + } + await self.clock.advance(by: .seconds(5)) + XCTAssertEqual(self.clock.now.offset, .seconds(5)) + try await task.value + } +} diff --git a/Tests/ClocksTests/UnimplementedClockTests.swift b/Tests/ClocksTests/UnimplementedClockTests.swift new file mode 100644 index 00000000..e56b483a --- /dev/null +++ b/Tests/ClocksTests/UnimplementedClockTests.swift @@ -0,0 +1,71 @@ +#if DEBUG + import AsyncAlgorithms + import Clocks + import XCTest + + @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) + @MainActor + final class UnimplementedClockTests: XCTestCase { + func testUnimplementedClock() async throws { + XCTExpectFailure { + [ + "Unimplemented: Clock.sleep", + "Unimplemented: Clock.now", + ] + .contains($0.compactDescription) + } + + let clock: some Clock = .unimplemented + try await clock.sleep(for: .seconds(1)) + } + + func testUnimplementedClock_WithName() async throws { + XCTExpectFailure { + [ + "Unimplemented: ContinuousClock.sleep", + "Unimplemented: ContinuousClock.now", + ] + .contains($0.compactDescription) + } + + let clock: some Clock = UnimplementedClock(name: "ContinuousClock") + try await clock.sleep(for: .seconds(1)) + } + + func testNow() async throws { + XCTExpectFailure { + [ + "Unimplemented: Clock.sleep", + "Unimplemented: Clock.now", + ] + .contains($0.compactDescription) + } + + let clock = UnimplementedClock() + try await clock.sleep(for: .seconds(5)) + XCTAssertEqual(clock.now.offset, .seconds(5)) + } + + func testCooperativeCancellation() async throws { + XCTExpectFailure { + [ + "Unimplemented: Clock.sleep", + "Unimplemented: Clock.now", + ] + .contains($0.compactDescription) + } + + let clock = UnimplementedClock() + let task = Task { + try? await Task.sleep(nanoseconds: NSEC_PER_SEC / 3) + try await clock.sleep(for: .seconds(1)) + } + task.cancel() + + do { + try await task.value + XCTFail("Task should have thrown an error") + } catch {} + } + } +#endif