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

Make Pendable resolvable after being called. #15

Merged
merged 10 commits into from
Apr 13, 2024
91 changes: 0 additions & 91 deletions Sources/Fakes/Pendable.swift

This file was deleted.

178 changes: 178 additions & 0 deletions Sources/Fakes/Pendable/Pendable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import Foundation

protocol ResolvableWithFallback {
func resolveWithFallback()
}

/// Pendable is a safe way to represent the 2 states that an asynchronous call can be in
///
/// - `pending`, the state while waiting for the call to finish.
/// - `finished`, the state once the call has finished.
///
/// Pendable allows you to finish a pending call after it's been made. This makes Pendable behave very
/// similarly to something like Combine's `Future`.
///
/// - Note: The reason you must provide a fallback value is to prevent deadlock when used in test.
/// Unlike something like Combine's `Future`, it is very often the case that you will write
/// tests which end while the call is in the pending state. If you do this too much, then your
/// entire test suite will deadlock, as Swift Concurrency works under the assumption that
/// blocked tasks of work will always eventually be unblocked. To help prevent this, pending calls
/// are always resolved with the fallback after a given delay. You can also manually force this
/// by calling the ``Pendable\resolveWithFallback()`` method.
public final class Pendable<Value: Sendable>: @unchecked Sendable, ResolvableWithFallback {
private enum State: Sendable {
case pending
case finished(Value)
}

private let lock = NSRecursiveLock()
private var state = State.pending

private var inProgressCalls = [UnsafeContinuation<Value, Never>]()

private let fallbackValue: Value

private var currentValue: Value {
switch state {
case .pending:
return fallbackValue
case .finished(let value):
return value
}
}

deinit {
resolveWithFallback()
}

/// Initializes a new `Pendable`, in a pending state, with the given fallback value.
public init(fallbackValue: Value) {
self.fallbackValue = fallbackValue
}

/// Gets the value for the `Pendable`, possibly waiting until it's resolved.
///
/// - parameter fallbackDelay: The amount of time (in seconds) to wait until the call returns
/// the fallback value. This is only used when the `Pendable` is in a pending state.
public func call(fallbackDelay: TimeInterval = PendableDefaults.delay) async -> Value {
return await withTaskGroup(of: Value.self) { taskGroup in
taskGroup.addTask { await self.handleCall() }
taskGroup.addTask { await self.resolveAfterDelay(fallbackDelay) }

guard let value = await taskGroup.next() else {
fatalError("There were no tasks in the task group. This should not ever happen.")
}
taskGroup.cancelAll()
return value

}
}

/// Resolves the `Pendable` with the fallback value.
///
/// - Note: This no-ops if the pendable is already in a resolved state.
/// - Note: This is called for when you re-stub a `Spy` in ``Spy/stub(_:)``
public func resolveWithFallback() {
lock.lock()
defer { lock.unlock() }

if case .pending = state {
resolve(with: fallbackValue)
}
}

/// Resolves the `Pendable` with the given value.
///
/// Even if the pendable is already resolves, this resets the resolved value to the given value.
public func resolve(with value: Value) {
lock.lock()
defer { lock.unlock() }
state = .finished(value)
inProgressCalls.forEach {
$0.resume(returning: value)
}
inProgressCalls = []

}

/// Resolves any outstanding calls to the `Pendable` with the current value,
/// and resets it back into the pending state.
public func reset() {
lock.lock()
defer { lock.unlock() }

inProgressCalls.forEach {
$0.resume(returning: currentValue)
}
inProgressCalls = []
state = .pending
}

// MARK: - Private
private func handleCall() async -> Value {
return await withUnsafeContinuation { continuation in
lock.lock()
defer { lock.unlock() }
switch state {
case .pending:
inProgressCalls.append(continuation)
case .finished(let value):
continuation.resume(returning: value)
}
}
}

private func resolveAfterDelay(_ delay: TimeInterval) async -> Value {
do {
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
} catch {}
resolveWithFallback()
return fallbackValue
}
}

public typealias ThrowingDynamicPendable<Success, Failure: Error> = Pendable<Result<Success, Failure>>

extension Pendable {
/// Gets or throws value for the `Pendable`, possibly waiting until it's resolved.
///
/// - parameter resolveDelay: The amount of time (in seconds) to wait until the call returns
/// the fallback value. This is only used when the `Pendable` is in a pending state.
public func call<Success, Failure: Error>(
resolveDelay: TimeInterval = PendableDefaults.delay
) async throws -> Success where Value == Result<Success, Failure> {
try await call(fallbackDelay: resolveDelay).get()
}
}

extension Pendable {
/// Creates a new finished `Pendable` pre-resolved with the given value.
public static func finished(_ value: Value) -> Pendable<Value> {
let pendable = Pendable(fallbackValue: value)
pendable.resolve(with: value)
return pendable
}

/// Creates a new finished `Pendable` pre-resolved with Void.
public static func finished() -> Pendable where Value == Void {
return Pendable.finished(())
}
}

extension Pendable {
/// Creates a new pending `Pendable` with the given fallback value.
public static func pending(fallback: Value) -> Pendable<Value> {
return Pendable(fallbackValue: fallback)
}

/// Creates a new pending `Pendable` with a fallback value of Void.
public static func pending() -> Pendable<Value> where Value == Void {
return Pendable(fallbackValue: ())
}

/// Creates a new pending `Pendable` with a fallback value of nil.
public static func pending<Wrapped>() -> Pendable<Value> where Value == Optional<Wrapped> {
// swiftlint:disable:previous syntactic_sugar
return Pendable(fallbackValue: nil)
}
}
36 changes: 36 additions & 0 deletions Sources/Fakes/Pendable/PendableDefaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

/// Default values for use with Pendable.
public final class PendableDefaults: @unchecked Sendable {
public static let shared = PendableDefaults()
private let lock = NSLock()

public init() {}

/// The amount of time to delay before resolving a pending Pendable with the fallback value.
/// By default this is 2 seconds. Conveniently, just long enough to be twice Nimble's default polling timeout.
/// In general, you should keep this set to some number greater than Nimble's default polling timeout,
/// in order to allow polling matchers to work correctly.
public static var delay: TimeInterval {
get {
PendableDefaults.shared.delay
}
set {
PendableDefaults.shared.delay = newValue
}
}

private var _delay: TimeInterval = 2
public var delay: TimeInterval {
get {
lock.lock()
defer { lock.unlock() }
return _delay
}
set {
lock.lock()
_delay = newValue
lock.unlock()
}
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ extension Spy {
}
}

extension Spy {
/// Resolve the pendable Spy's stub with Void
public func resolveStub() where Returning == Pendable<Void> {
self.resolveStub(with: ())
}
}

extension Spy {
/// Update the pendable Spy's stub to be in a pending state.
public func stub<Value>(pendingFallback: Value) where Returning == Pendable<Value> {
Expand All @@ -30,6 +37,12 @@ extension Spy {
self.stub(.pending(fallback: ()))
}

/// Update the pendable Spy's stub to be in a pending state.
public func stubPending<Wrapped>() where Returning == Pendable<Optional<Wrapped>> {
// swiftlint:disable:previous syntactic_sugar
self.stub(.pending(fallback: nil))
}

/// Update the pendable Spy's stub to return the given value.
///
/// - parameter finished: The value to return when `callAsFunction` is called.
Expand All @@ -44,31 +57,25 @@ extension Spy {
}

extension Spy {
/// Records the arguments and handles the result according to ``Pendable/resolve(delay:)-hvhg``.
/// Records the arguments and handles the result according to ``Pendable/call(fallbackDelay:)``.
///
/// - parameter arguments: The arguments to record.
/// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before
/// returning the `pendingFallback`. If the `Pendable` is .finished, then this value is ignored.
///
/// Because of how ``Pendable`` currently works, you must provide a fallback option for when the Pendable is pending.
/// Alternatively, you can use the throwing version of `callAsFunction`, which will thorw an error instead of returning the fallback.
/// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before
/// returning its fallback value. If the `Pendable` is finished, then this value is ignored.
public func callAsFunction<Value>(
_ arguments: Arguments,
pendingDelay: TimeInterval = PendableDefaults.delay
fallbackDelay: TimeInterval = PendableDefaults.delay
) async -> Value where Returning == Pendable<Value> {
return await call(arguments).resolve(delay: pendingDelay)
return await call(arguments).call(fallbackDelay: fallbackDelay)
}

/// Records that a call was made and handles the result according to ``Pendable/resolve(delay:)-hvhg``.
///
/// - parameter pendingDelay: The amount of seconds to delay if the `Pendable` is .pending before
/// returning the `pendingFallback`. If the `Pendable` is .finished, then this value is ignored.
/// Records that a call was made and handles the result according to ``Pendable/call(fallbackDelay:)``.
///
/// Because of how ``Pendable`` currently works, you must provide a fallback option for when the Pendable is pending.
/// Alternatively, you can use the throwing version of `callAsFunction`, which will thorw an error instead of returning the fallback.
/// - parameter fallbackDelay: The amount of seconds to delay if the `Pendable` is pending before
/// returning its fallback value. If the `Pendable` is finished, then this value is ignored.
public func callAsFunction<Value>(
pendingDelay: TimeInterval = PendableDefaults.delay
fallbackDelay: TimeInterval = PendableDefaults.delay
) async -> Value where Arguments == Void, Returning == Pendable<Value> {
return await call(()).resolve(delay: pendingDelay)
return await call(()).call(fallbackDelay: fallbackDelay)
}
}
File renamed without changes.
Loading