diff --git a/Sources/CombineSchedulers/TestScheduler.swift b/Sources/CombineSchedulers/TestScheduler.swift index b82be1b..89fd01e 100644 --- a/Sources/CombineSchedulers/TestScheduler.swift +++ b/Sources/CombineSchedulers/TestScheduler.swift @@ -72,15 +72,18 @@ private var lastSequence: UInt = 0 private let lock = NSRecursiveLock() - public let minimumTolerance: SchedulerTimeType.Stride = .zero + public let minimumTolerance: SchedulerTimeType.Stride public private(set) var now: SchedulerTimeType private var scheduled: [(sequence: UInt, date: SchedulerTimeType, action: () -> Void)] = [] /// Creates a test scheduler with the given date. /// /// - Parameter now: The current date of the test scheduler. - public init(now: SchedulerTimeType) { + /// - Parameter minimumTolerance: An additional delay which will be added to the `.now` + /// property before executing units of work. + public init(now: SchedulerTimeType, minimumTolerance: SchedulerTimeType.Stride = .zero) { self.now = now + self.minimumTolerance = minimumTolerance } /// Advances the scheduler by the given stride. @@ -101,7 +104,7 @@ public func advance(by duration: SchedulerTimeType.Stride = .zero) async { await self.advance(to: self.now.advanced(by: duration)) } - + /// Advances the scheduler to the given instant. /// /// - Parameter instant: An instant in time to advance to. @@ -109,7 +112,7 @@ while self.lock.sync(operation: { self.now }) <= instant { self.lock.lock() self.scheduled.sort { ($0.date, $0.sequence) < ($1.date, $1.sequence) } - + guard let next = self.scheduled.first, instant >= next.date @@ -118,7 +121,7 @@ self.lock.unlock() return } - + self.now = next.date self.scheduled.removeFirst() self.lock.unlock() @@ -145,7 +148,7 @@ self.lock.unlock() return true } - + self.now = next.date self.scheduled.removeFirst() self.lock.unlock() @@ -211,18 +214,27 @@ _ action: @escaping () -> Void ) -> Cancellable { let sequence = self.lock.sync { self.nextSequence() } - + let minimumTolerance = self.minimumTolerance + func scheduleAction(for date: SchedulerTimeType) -> () -> Void { return { [weak self] in let nextDate = date.advanced(by: interval) self?.lock.sync { - self?.scheduled.append((sequence, nextDate, scheduleAction(for: nextDate))) + self?.scheduled.append(( + sequence, + nextDate.advanced(by: minimumTolerance), + scheduleAction(for: nextDate) + )) } action() } } - self.lock.sync { self.scheduled.append((sequence, date, scheduleAction(for: date))) } + self.lock.sync { self.scheduled.append(( + sequence, + date.advanced(by: minimumTolerance), + scheduleAction(for: date) + )) } return AnyCancellable { [weak self] in self?.lock.sync { self?.scheduled.removeAll(where: { $0.sequence == sequence }) } @@ -235,7 +247,11 @@ options _: SchedulerOptions?, _ action: @escaping () -> Void ) { - self.lock.sync { self.scheduled.append((self.nextSequence(), date, action)) } + self.lock.sync { self.scheduled.append(( + self.nextSequence(), + date.advanced(by: minimumTolerance), + action + )) } } public func schedule(options _: SchedulerOptions?, _ action: @escaping () -> Void) { @@ -254,6 +270,13 @@ // NB: `DispatchTime(uptimeNanoseconds: 0) == .now())`. Use `1` for consistency. .init(now: .init(.init(uptimeNanoseconds: 1))) } + + /// A test scheduler of dispatch queues that simulates the inaccuracy of real schedulers. + public static func inaccurate( + by minimumTolerance: SchedulerTimeType.Stride + ) -> TestSchedulerOf { + .init(now: .init(.init(uptimeNanoseconds: 1)), minimumTolerance: minimumTolerance) + } } extension UIScheduler { @@ -262,6 +285,15 @@ // NB: `DispatchTime(uptimeNanoseconds: 0) == .now())`. Use `1` for consistency. .init(now: .init(.init(uptimeNanoseconds: 1))) } + + /// A test scheduler compatible with type erased UI schedulers that simulates the inaccuracy of + /// real schedulers. + @_disfavoredOverload + public static func test( + minimumTolerance: SchedulerTimeType.Stride + ) -> TestSchedulerOf { + .init(now: .init(.init(uptimeNanoseconds: 1)), minimumTolerance: minimumTolerance) + } } extension OperationQueue { @@ -269,6 +301,14 @@ public static var test: TestSchedulerOf { .init(now: .init(.init(timeIntervalSince1970: 0))) } + + /// A test scheduler of operations queues that simulates the inaccuracy of real schedulers. + @_disfavoredOverload + public static func test( + minimumTolerance: SchedulerTimeType.Stride + ) -> TestSchedulerOf { + .init(now: .init(.init(timeIntervalSince1970: 0)), minimumTolerance: minimumTolerance) + } } extension RunLoop { @@ -276,6 +316,14 @@ public static var test: TestSchedulerOf { .init(now: .init(.init(timeIntervalSince1970: 0))) } + + /// A test scheduler of run loops that simulates the inaccuracy of real schedulers. + @_disfavoredOverload + public static func test( + minimumTolerance: SchedulerTimeType.Stride + ) -> TestSchedulerOf { + .init(now: .init(.init(timeIntervalSince1970: 0)), minimumTolerance: minimumTolerance) + } } /// A convenience type to specify a `TestScheduler` by the scheduler it wraps rather than by the diff --git a/Tests/CombineSchedulersTests/TestSchedulerTests.swift b/Tests/CombineSchedulersTests/TestSchedulerTests.swift index 3ee0511..bbfdc36 100644 --- a/Tests/CombineSchedulersTests/TestSchedulerTests.swift +++ b/Tests/CombineSchedulersTests/TestSchedulerTests.swift @@ -33,6 +33,94 @@ final class CombineSchedulerTests: XCTestCase { XCTAssertEqual(value, 1) } + + func testAdvanceWithInaccuracy() { + let scheduler = DispatchQueue.inaccurate(by: .milliseconds(50)) + let start = scheduler.now + var time: DispatchQueue.SchedulerTimeType? + + Just(()) + .delay(for: 1, scheduler: scheduler) + .sink { time = scheduler.now } + .store(in: &cancellables) + + XCTAssertEqual(time, nil) + + scheduler.advance(by: .milliseconds(500)) + + XCTAssertEqual(time, nil) + + scheduler.advance(by: .milliseconds(500)) + + XCTAssertEqual(time, nil) + + scheduler.advance(by: .milliseconds(50)) + + XCTAssertEqual( + time, + start + .advanced(by: .seconds(1)) + .advanced(by: .milliseconds(50)) + ) + + XCTAssertEqual( + scheduler.now, + start + .advanced(by: .seconds(1)) + .advanced(by: .milliseconds(50)) + ) + } + + func testAdvanceToWithInaccuracy() { + let scheduler = DispatchQueue.inaccurate(by: .milliseconds(50)) + let start = scheduler.now + var time: DispatchQueue.SchedulerTimeType? + + Just(()) + .delay(for: 1, scheduler: scheduler) + .sink { time = scheduler.now } + .store(in: &self.cancellables) + + XCTAssertEqual(time, nil) + + scheduler.advance(to: start.advanced(by: .milliseconds(500))) + + XCTAssertEqual(time, nil) + + scheduler.advance(to: start.advanced(by: .milliseconds(1000))) + + XCTAssertEqual(time, nil) + + scheduler.advance(to: start.advanced(by: .milliseconds(1050))) + + XCTAssertEqual( + time, + start + .advanced(by: .seconds(1)) + .advanced(by: .milliseconds(50)) + ) + } + + func testInaccruateAsyncTimer() async throws { + let scheduler = DispatchQueue.inaccurate(by: .milliseconds(50)) + let start = scheduler.now + + let task = Task { + var times = [DispatchQueue.SchedulerTimeType]() + for await time in scheduler.timer(interval: .seconds(1)).prefix(3) { + times.append(time) + } + return times + } + + await scheduler.advance(by: .seconds(10)) + let times = await task.value + XCTAssertEqual(times, [ + start.advanced(by: .milliseconds(1050)), + start.advanced(by: .milliseconds(2050)), + start.advanced(by: .milliseconds(3050)), + ]) + } func testAdvanceTo() { let scheduler = DispatchQueue.test