Skip to content

Commit ac0e6ec

Browse files
committed
chore: use testclock directly instead of referencing pointfrees swift-clocks
1 parent 27e4396 commit ac0e6ec

File tree

4 files changed

+260
-44
lines changed

4 files changed

+260
-44
lines changed

Package.resolved

Lines changed: 0 additions & 33 deletions
This file was deleted.

Package.swift

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// swift-tools-version: 6.0
2-
32
import PackageDescription
43

54
let package = Package(
@@ -8,20 +7,13 @@ let package = Package(
87
products: [
98
.library(name: "Deadline", targets: ["Deadline"]),
109
],
11-
dependencies: [
12-
.package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0")
13-
],
1410
targets: [
1511
.target(
1612
name: "Deadline"
1713
),
1814
.testTarget(
1915
name: "DeadlineTests",
20-
dependencies: [
21-
"Deadline",
22-
.product(name: "Clocks", package: "swift-clocks")
23-
]
16+
dependencies: ["Deadline"]
2417
)
25-
],
26-
swiftLanguageModes: [.v6]
18+
]
2719
)

Tests/DeadlineTests/DeadlineTests.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
#if canImport(Testing)
2-
import Clocks
32
import Deadline
43
import Testing
54

Tests/DeadlineTests/TestClock.swift

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// MIT License
2+
//
3+
// Copyright (c) 2023 Point-Free
4+
//
5+
// Permission is hereby granted, free of charge, to any person obtaining a copy
6+
// of this software and associated documentation files (the "Software"), to deal
7+
// in the Software without restriction, including without limitation the rights
8+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
// copies of the Software, and to permit persons to whom the Software is
10+
// furnished to do so, subject to the following conditions:
11+
//
12+
// The above copyright notice and this permission notice shall be included in all
13+
// copies or substantial portions of the Software.
14+
//
15+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
// SOFTWARE.
22+
23+
#if canImport(Testing)
24+
import Testing
25+
import Foundation
26+
27+
final class TestClock<Duration: DurationProtocol & Hashable>: Clock, @unchecked Sendable {
28+
struct Instant: InstantProtocol {
29+
fileprivate let offset: Duration
30+
31+
init(offset: Duration = .zero) {
32+
self.offset = offset
33+
}
34+
35+
func advanced(by duration: Duration) -> Self {
36+
.init(offset: self.offset + duration)
37+
}
38+
39+
func duration(to other: Self) -> Duration {
40+
other.offset - self.offset
41+
}
42+
43+
static func < (lhs: Self, rhs: Self) -> Bool {
44+
lhs.offset < rhs.offset
45+
}
46+
}
47+
48+
var minimumResolution: Duration = .zero
49+
private(set) var now: Instant
50+
51+
private let lock = NSRecursiveLock()
52+
private var suspensions:
53+
[(
54+
id: UUID,
55+
deadline: Instant,
56+
continuation: AsyncThrowingStream<Never, Error>.Continuation
57+
)] = []
58+
59+
init(now: Instant = .init()) {
60+
self.now = now
61+
}
62+
63+
func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws {
64+
try Task.checkCancellation()
65+
let id = UUID()
66+
do {
67+
let stream: AsyncThrowingStream<Never, Error>? = self.lock.sync {
68+
guard deadline >= self.now
69+
else {
70+
return nil
71+
}
72+
return AsyncThrowingStream<Never, Error> { continuation in
73+
self.suspensions.append((id: id, deadline: deadline, continuation: continuation))
74+
}
75+
}
76+
guard let stream = stream
77+
else { return }
78+
for try await _ in stream {}
79+
try Task.checkCancellation()
80+
} catch is CancellationError {
81+
self.lock.sync { self.suspensions.removeAll(where: { $0.id == id }) }
82+
throw CancellationError()
83+
} catch {
84+
throw error
85+
}
86+
}
87+
88+
/// Throws an error if there are active sleeps on the clock.
89+
///
90+
/// This can be useful for proving that your feature will not perform any more time-based
91+
/// asynchrony. For example, the following will throw because the clock has an active suspension
92+
/// scheduled:
93+
///
94+
/// ```swift
95+
/// let clock = TestClock()
96+
/// Task {
97+
/// try await clock.sleep(for: .seconds(1))
98+
/// }
99+
/// try await clock.checkSuspension()
100+
/// ```
101+
///
102+
/// However, the following will not throw because advancing the clock has finished the suspension:
103+
///
104+
/// ```swift
105+
/// let clock = TestClock()
106+
/// Task {
107+
/// try await clock.sleep(for: .seconds(1))
108+
/// }
109+
/// await clock.advance(for: .seconds(1))
110+
/// try await clock.checkSuspension()
111+
/// ```
112+
func checkSuspension() async throws {
113+
await Task.megaYield()
114+
guard self.lock.sync(operation: { self.suspensions.isEmpty })
115+
else { throw SuspensionError() }
116+
}
117+
118+
/// Advances the test clock's internal time by the duration.
119+
///
120+
/// See the documentation for ``TestClock`` to see how to use this method.
121+
func advance(by duration: Duration = .zero) async {
122+
await self.advance(to: self.lock.sync(operation: { self.now.advanced(by: duration) }))
123+
}
124+
125+
/// Advances the test clock's internal time to the deadline.
126+
///
127+
/// See the documentation for ``TestClock`` to see how to use this method.
128+
func advance(to deadline: Instant) async {
129+
while self.lock.sync(operation: { self.now <= deadline }) {
130+
await Task.megaYield()
131+
let `return` = {
132+
self.lock.lock()
133+
self.suspensions.sort { $0.deadline < $1.deadline }
134+
135+
guard
136+
let next = self.suspensions.first,
137+
deadline >= next.deadline
138+
else {
139+
self.now = deadline
140+
self.lock.unlock()
141+
return true
142+
}
143+
144+
self.now = next.deadline
145+
self.suspensions.removeFirst()
146+
self.lock.unlock()
147+
next.continuation.finish()
148+
return false
149+
}()
150+
151+
if `return` {
152+
await Task.megaYield()
153+
return
154+
}
155+
}
156+
await Task.megaYield()
157+
}
158+
159+
/// Runs the clock until it has no scheduled sleeps left.
160+
///
161+
/// This method is useful for letting a clock run to its end without having to explicitly account
162+
/// for each sleep. For example, suppose you have a feature that runs a timer for 10 ticks, and
163+
/// each tick it increments a counter. If you don't want to worry about advancing the timer for
164+
/// each tick, you can instead just `run` the clock out:
165+
///
166+
/// ```swift
167+
/// func testTimer() async {
168+
/// let clock = TestClock()
169+
/// let model = FeatureModel(clock: clock)
170+
///
171+
/// XCTAssertEqual(model.count, 0)
172+
/// model.startTimerButtonTapped()
173+
///
174+
/// await clock.run()
175+
/// XCTAssertEqual(model.count, 10)
176+
/// }
177+
/// ```
178+
///
179+
/// It is possible to run a clock that never finishes, hence causing a suspension that never
180+
/// finishes. This can happen if you create an unbounded timer. In order to prevent holding up
181+
/// your test suite forever, the ``run(timeout:file:line:)`` method will terminate and cause a
182+
/// test failure if a timeout duration is reached.
183+
///
184+
/// - Parameters:
185+
/// - duration: The amount of time to allow for all work on the clock to finish.
186+
func run(
187+
timeout duration: Swift.Duration = .milliseconds(500),
188+
fileID: StaticString = #fileID,
189+
filePath: StaticString = #filePath,
190+
line: UInt = #line,
191+
column: UInt = #column
192+
) async {
193+
do {
194+
try await withThrowingTaskGroup(of: Void.self) { group in
195+
group.addTask {
196+
try await Task.sleep(until: .now.advanced(by: duration), clock: .continuous)
197+
for suspension in self.suspensions {
198+
suspension.continuation.finish(throwing: CancellationError())
199+
}
200+
throw CancellationError()
201+
}
202+
group.addTask {
203+
await Task.megaYield()
204+
while let deadline = self.lock.sync(operation: { self.suspensions.first?.deadline }) {
205+
try Task.checkCancellation()
206+
await self.advance(by: self.lock.sync(operation: { self.now.duration(to: deadline) }))
207+
}
208+
}
209+
try await group.next()
210+
group.cancelAll()
211+
}
212+
} catch {
213+
let comment = Comment(rawValue:
214+
"""
215+
Expected all sleeps to finish, but some are still suspending after \(duration).
216+
217+
There are sleeps suspending. This could mean you are not advancing the test clock far \
218+
enough for your feature to execute its logic, or there could be a bug in your feature's \
219+
logic.
220+
221+
You can also increase the timeout of 'run' to be greater than \(duration).
222+
"""
223+
)
224+
Issue.record(comment)
225+
}
226+
}
227+
}
228+
229+
/// An error that indicates there are actively suspending sleeps scheduled on the clock.
230+
///
231+
/// This error is thrown automatically by ``TestClock/checkSuspension()`` if there are actively
232+
/// suspending sleeps scheduled on the clock.
233+
struct SuspensionError: Error {}
234+
235+
extension Task where Success == Never, Failure == Never {
236+
static func megaYield(count: Int = 20) async {
237+
for _ in 0..<count {
238+
await Task<Void, Never>.detached(priority: .background) { await Task.yield() }.value
239+
}
240+
}
241+
}
242+
243+
extension NSRecursiveLock {
244+
@inlinable
245+
@discardableResult
246+
func sync<R>(operation: () -> R) -> R {
247+
self.lock()
248+
defer { self.unlock() }
249+
return operation()
250+
}
251+
}
252+
253+
extension TestClock where Duration == Swift.Duration {
254+
convenience init() {
255+
self.init(now: .init())
256+
}
257+
}
258+
#endif

0 commit comments

Comments
 (0)