// test throttling as user enters “hello, world” into a text field
func testThrottler() async throws {
// Given
let promise = expectation(description: "Ensure first task fired")
let throttler = Limiter(policy: .throttle, duration: 1)
var valuefulfillmentCount = ""
0
var fulfillmentCountpromise.expectedFulfillmentCount = 02
promise.expectedFulfillmentCountvar value = 2""
func sendToServeraccumulateAndSendToServer(_ input: String) async {
awaitvalue throttler.submit+= {input
await throttler.submit value{ +=[value] input
in
// Then
switch fulfillmentCount {
case 0:
XCTAssertEqual(value, "h")
case 1:
XCTAssertEqual(value, "hwor""hello,")
default:
XCTFail()
}
promise.fulfill()
fulfillmentCount += 1
}
}
// When
await sendToServeraccumulateAndSendToServer("h")
await sendToServeraccumulateAndSendToServer("e")
await sendToServeraccumulateAndSendToServer("l")
await sendToServeraccumulateAndSendToServer("l")
await sendToServeraccumulateAndSendToServer("o")
try await Task.sleep(seconds: 2)
await sendToServeraccumulateAndSendToServer("wor"",")
await sendToServeraccumulateAndSendToServer("ld"" ")
await accumulateAndSendToServer("w")
await accumulateAndSendToServer("o")
await accumulateAndSendToServer("r")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("d")
wait(for: [promise], timeout: 10)
}
Repeat that process for all the tests, and you'll see it works fine.
In debounce, the test for isCancelled is redundant. The try await Task.sleep will throw an error if the task was canceled.
As a matter of convention, Apple uses operation for the name of the closure parameters, presumably to avoid confusion with Task instances.
I would change the Task to be a Task<Void, Error>?. Then you can simplify debounce to:
func debounce(operation: @escaping () async -> Void) {
task?.cancel()
task = Task {
trydefer await{ sleep()task = nil }
try await operationTask.sleep(seconds: duration)
task =await niloperation()
}
}
When throttling network requests for user input, you generally want to throttle the network requests, but not the accumulation of the user input. So I have pulled the value += input out of the throttler/debouncer. I also use a capture list of [value] to make sure that we avoid race conditions between the accumulation of user input and the network requests.
actor Limiter {
private let policy: Policy
private let duration: TimeInterval
private var task: Task<Void, Error>?
init(policy: Policy, duration: TimeInterval) {
self.policy = policy
self.duration = duration
}
func submit(operation: @escaping () async -> Void) {
switch policy {
case .throttle: throttle(operation: operation)
case .debounce: debounce(operation: operation)
}
}
}
// MARK: - Limiter.Policy
extension Limiter {
enum Policy {
case throttle
case debounce
}
}
// MARK: - Private utility methods
private extension Limiter {
func throttle(operation: @escaping () async -> Void) {
guard task == nil else { return }
task = Task {
try?defer await{ sleep()task = nil }
tasktry =await nilTask.sleep(seconds: duration)
}
Task {
await operation()
}
}
func debounce(operation: @escaping () async -> Void) {
task?.cancel()
task = Task {
try await sleep()
await operation()
defer { task = nil
}
}
}
try funcawait Task.sleep(seconds: duration)
async throws {
try await Task.sleep(nanoseconds: UInt64operation(duration * .nanosecondsPerSecond))
}
}
// MARK: - TimeInterval
extension TimeInterval {}
static let nanosecondsPerSecond = TimeInterval(NSEC_PER_SEC)}
}
Which uses these extensions
// MARK: - Task.sleep(seconds:)
extension Task where Success == Never, Failure == Never {
/// Suspends the current task for at least the given duration
/// in seconds.
///
/// If the task is canceled before the time ends,
/// this function throws `CancellationError`.
///
/// This function doesn't block the underlying thread.
public static func sleep(seconds duration: TimeInterval) async throws {
try await Task.sleep(nanoseconds: UInt64(duration * .nanosecondsPerSecond))
}
}
// MARK: - TimeInterval
extension TimeInterval {
static let nanosecondsPerSecond = TimeInterval(NSEC_PER_SEC)
}
final class LimiterTests: XCTestCase {
// test throttling as user enters “hello, world” into a text field
func testThrottler() async throws {
// Given
let promise = expectation(description: "Ensure first task fired")
let throttler = Limiter(policy: .throttle, duration: 1)
var value = ""
var fulfillmentCount = 0
promise.expectedFulfillmentCount = 2
var value = ""
func sendToServeraccumulateAndSendToServer(_ input: String) async {
await throttler.submit {
value += input
await throttler.submit { [value] in
// Then
switch fulfillmentCount {
case 0: XCTAssertEqual(value, "h")
case 1: XCTAssertEqual(value, "hwor""hello,")
default: XCTFail()
}
promise.fulfill()
fulfillmentCount += 1
}
}
// When
await sendToServeraccumulateAndSendToServer("h")
await sendToServeraccumulateAndSendToServer("e")
await sendToServeraccumulateAndSendToServer("l")
await sendToServeraccumulateAndSendToServer("l")
await sendToServeraccumulateAndSendToServer("o")
try await Task.sleep(seconds: 2)
await sendToServeraccumulateAndSendToServer("wor"",")
await sendToServeraccumulateAndSendToServer("ld"" ")
await accumulateAndSendToServer("w")
await accumulateAndSendToServer("o")
await accumulateAndSendToServer("r")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("d")
wait(for: [promise], timeout: 10)
}
// test debouncing as user enters “hello, world” into a text field
func testDebouncer() async throws {
// Given
let promise = expectation(description: "Ensure last task fired")
let debouncer = Limiter(policy: .debounce, duration: 1)
var value = ""
var fulfillmentCount = 0
promise.expectedFulfillmentCount = 2
func sendToServeraccumulateAndSendToServer(_ input: String) async {
await debouncer.submit {
value += input
await debouncer.submit { [value] in
// Then
switch fulfillmentCount {
case 0: XCTAssertEqual(value, "o""hello")
case 1: XCTAssertEqual(value, "old""hello, world")
default: XCTFail()
}
promise.fulfill()
fulfillmentCount += 1
}
}
// When
await sendToServeraccumulateAndSendToServer("h")
await sendToServeraccumulateAndSendToServer("e")
await sendToServeraccumulateAndSendToServer("l")
await sendToServeraccumulateAndSendToServer("l")
await sendToServeraccumulateAndSendToServer("o")
try await Task.sleep(seconds: 2)
await sendToServeraccumulateAndSendToServer("wor"",")
await sendToServeraccumulateAndSendToServer("ld"" ")
await accumulateAndSendToServer("w")
await accumulateAndSendToServer("o")
await accumulateAndSendToServer("r")
await accumulateAndSendToServer("l")
await accumulateAndSendToServer("d")
wait(for: [promise], timeout: 10)
}
func testThrottler2() async throws {
// Given
let promise = expectation(description: "Ensure throttle before duration")
let throttler = Limiter(policy: .throttle, duration: 1)
var end = Date.now + 1
promise.expectedFulfillmentCount = 2
func test() {
// Then
XCTAssertLessThanOrEqual(.now, end)
promise.fulfill()
}
// When
await throttler.submit(operation: test)
await throttler.submit(operation: test)
await throttler.submit(operation: test)
await throttler.submit(operation: test)
await throttler.submit(operation: test)
try await Task.sleep(seconds: 2)
end = .now + 1
await throttler.submit(operation: test)
await throttler.submit(operation: test)
await throttler.submit(operation: test)
try await Task.sleep(seconds: 2)
wait(for: [promise], timeout: 10)
}
func testDebouncer2() async throws {
// Given
let promise = expectation(description: "Ensure debounce after duration")
let debouncer = Limiter(policy: .debounce, duration: 1)
var end = Date.now + 1
promise.expectedFulfillmentCount = 2
func test() {
// Then
XCTAssertGreaterThanOrEqual(.now, end)
promise.fulfill()
}
// When
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
try await Task.sleep(seconds: 2)
end = .now + 1
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
await debouncer.submit(operation: test)
try await Task.sleep(seconds: 2)
wait(for: [promise], timeout: 10)
}
private func sleep(_ duration: TimeInterval) async {
try? await Task.sleep(nanoseconds: UInt64(duration * .nanosecondsPerSecond))
}
}