Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added inaccurate test schedulers #72

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions Sources/CombineSchedulers/TestScheduler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -101,15 +104,15 @@
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.
public func advance(to instant: SchedulerTimeType) {
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
Expand All @@ -118,8 +121,8 @@
self.lock.unlock()
return
}

self.now = next.date
self.now = next.date.advanced(by: minimumTolerance)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my hunch was that a call to scheduler.advance() would incur the minimum tolerance and no more, but it sounds like this will incur the minimum tolerance for every work item, which seems a little less easy to understand from the outside, and more prone to causing test failures when your code changes. What do you think about calling it a single time instead of in the loop?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea was to replicate in a predictable way, how schedulers work in real life. If I set a timer for 1 second, it's never just exactly 1 second. There is always an additional bit of time albeit very small. But it can add up quite quickly if not accounted for.

If there are several units of work scheduled, you would want them all to be triggered at their scheduled date plus the the delay which you specified when you create the scheduler.

Perhaps it's confusing using the minimum tolerance for this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thought on this.

As the motivation for this is to replicate the slight difference between the scheduled time and the time the action actually takes place, it might make more sense to update the schedule date directly as the action is scheduled.

This way it's easier to reason. You know if you have a test scheduler with a drift value of 0.05 and you schedule some work at 1 second, that it will occur once the scheduler advances 1.05 seconds.

The reason you might want this is if you are performing repeated scheduling and precision matters to you. If you know what time an action was scheduled for you can take the difference between the scheduled time and the actual time, and offset the next scheduled event.

self.scheduled.removeFirst()
self.lock.unlock()
next.action()
Expand All @@ -145,8 +148,8 @@
self.lock.unlock()
return true
}

self.now = next.date
self.now = next.date.advanced(by: minimumTolerance)
self.scheduled.removeFirst()
self.lock.unlock()
next.action()
Expand Down Expand Up @@ -254,6 +257,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<DispatchQueue> {
.init(now: .init(.init(uptimeNanoseconds: 1)), minimumTolerance: minimumTolerance)
}
}

extension UIScheduler {
Expand All @@ -262,20 +272,42 @@
// 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.
public static func inaccurate(
by minimumTolerance: SchedulerTimeType.Stride
runloop marked this conversation as resolved.
Show resolved Hide resolved
) -> TestSchedulerOf<UIScheduler> {
.init(now: .init(.init(uptimeNanoseconds: 1)), minimumTolerance: minimumTolerance)
}
}

extension OperationQueue {
/// A test scheduler of operation queues.
public static var test: TestSchedulerOf<OperationQueue> {
.init(now: .init(.init(timeIntervalSince1970: 0)))
}

/// A test scheduler of operations queues that simulates the inaccuracy of real schedulers.
public static func inaccurate(
by minimumTolerance: SchedulerTimeType.Stride
runloop marked this conversation as resolved.
Show resolved Hide resolved
) -> TestSchedulerOf<OperationQueue> {
.init(now: .init(.init(timeIntervalSince1970: 0)), minimumTolerance: minimumTolerance)
}
}

extension RunLoop {
/// A test scheduler of run loops.
public static var test: TestSchedulerOf<RunLoop> {
.init(now: .init(.init(timeIntervalSince1970: 0)))
}

/// A test scheduler of run loops that simulates the inaccuracy of real schedulers.
public static func inaccurate(
by minimumTolerance: SchedulerTimeType.Stride
runloop marked this conversation as resolved.
Show resolved Hide resolved
) -> TestSchedulerOf<RunLoop> {
.init(now: .init(.init(timeIntervalSince1970: 0)), minimumTolerance: minimumTolerance)
}
}

/// A convenience type to specify a `TestScheduler` by the scheduler it wraps rather than by the
Expand Down
70 changes: 69 additions & 1 deletion Tests/CombineSchedulersTests/TestSchedulerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,40 @@ final class CombineSchedulerTests: XCTestCase {

XCTAssertEqual(value, 1)
}

func testAdvanceWithInaccuracy() {
let scheduler = DispatchQueue.inaccurate(by: .milliseconds(50))
var 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(250))

XCTAssertEqual(time, nil)

scheduler.advance(by: .milliseconds(250))

XCTAssertEqual(time, nil)

scheduler.advance(by: .milliseconds(250))

XCTAssertEqual(time, nil)

scheduler.advance(by: .milliseconds(250))

XCTAssertEqual(
time,
start
.advanced(by: .seconds(1))
.advanced(by: .milliseconds(50))
)
}

func testAdvanceTo() {
let scheduler = DispatchQueue.test
Expand Down Expand Up @@ -62,9 +96,43 @@ final class CombineSchedulerTests: XCTestCase {

XCTAssertEqual(value, 1)
}

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(250)))

XCTAssertEqual(time, nil)

scheduler.advance(to: start.advanced(by: .milliseconds(500)))

XCTAssertEqual(time, nil)

scheduler.advance(to: start.advanced(by: .milliseconds(750)))

XCTAssertEqual(time, nil)

scheduler.advance(to: start.advanced(by: .milliseconds(1000)))

XCTAssertEqual(
time,
start
.advanced(by: .seconds(1))
.advanced(by: .milliseconds(50))
)
}

func testRunScheduler() {
let scheduler = DispatchQueue.test
let scheduler = DispatchQueue.test

var value: Int?
Just(1)
Expand Down