Point-Free TCA ep.4
Test
Sort Test
- Because of the Effect which is still running, the test will fail.
An effect returned for this action is still running. It must complete before the end of the test. …
- The only way to make that happen is to literally have the test suite wait for a second. The assertmethod supports inserting little imperative tasks like that in between steps, and it’s called adoblock
store.assert(
	.send(.todo(index: 0, action: .checkboxTapped)) {
		(...)
	},
	.do {
		// Do any imperative work
	}
)
.do {
	// Do any imperative work
	_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
}
- Now if we run tests we get a new failure:
failed - Received 1 unexpected action: … Unhandled actions: [ AppAction.todoDelayCompleted, ]
- to prove that we expected to receive this action we need to add an additional step to our assertion
.do {
	// Do any imperative work
	_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
},
.receive(.todoDelayCompleted)
- Now we can see the logic properly
- For this, we add the closure.
.receive(.todoDelayCompleted) {
	$0.todos.swapAt(0, 1)
}
Complete ToDo
- same as the upper case, we need to add the .dothing
store.assert(
	.send(.todo(index: 0, action: .checkboxTapped)) {
		// make the mutations we expect to happen to the value passed to the closure.
		$0.todos[0].isComplete = true
	},
	.do {
		// Do any imperative work
		_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
	}
)
- And then, we can face the error message:
testCompletingTodo(): The store received 1 unexpected action after this one: … Unhandled actions: [ [0]: .todoDelayCompleted ]
- the solution is the same, too.
store.assert(
	.send(.todo(index: 0, action: .checkboxTapped)) {
		// make the mutations we expect to happen to the value passed to the closure.
		$0.todos[0].isComplete = true
	},
	.do {
		// Do any imperative work
		_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
	},
	.receive(.todoDelayCompleted)
)
Cancellation
- Paste the test function of sorting
- We want to test the cancellation, so we need to tap the checkbox twice.
store.assert(
	.send(.todo(index: 0, action: .checkboxTapped)) {
		$0.todos[0].isComplete = true
	},
	.do {
		// Do any imperative work
		_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 0.5)
	},
	.send(.todo(index: 0, action: .checkboxTapped)) {
		$0.todos[0].isComplete = false
	},
	.do {
		// Do any imperative work
		_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 1)
	},
	.receive(.todoDelayCompleted)
)
Debounce
- The combination of delaying an effect and cancelling inflight effects when starting a new one has a name that is well-known in the reactive programming communities: it’s called debounce.
case .todo(index: \_, action: .checkboxTapped):
	struct CancelDelayId: Hashable {}
	return Effect(value: AppAction.todoDelayCompleted)
		.delay(for: 1, scheduler: DispatchQueue.main)
		.eraseToEffect()
		.cancellable(id: CancelDelayId(), cancelInFlight: true)
- This code can be simplified in 1 line.
case .todo(index: \_, action: .checkboxTapped):
	struct CancelDelayId: Hashable {}
	return Effect(value: AppAction.todoDelayCompleted)
		.debounce(id: CancelDelayId(), for: 1, scheduler: DispatchQueue.main)
- There are also other type of debouncewhich hasn't theidas a parameter.
The Reason why debouncing is problematic
- It takes 3.5 seconds to running the test
Test Suite 'TCA_ToDoListTests' passed at 2023-03-10 15:23:10.790. Executed 4 tests, with 0 failures (0 unexpected) in 3.532 (3.536) seconds
- 
    That means 99.5% of the test suite time is spent waiting. Worse, if we have dozens or hundreds of these tests that time could easily add up to minutes of wasted time. 
- So we need the tool for time controlling
- 
    Before using this tool, we need to know well about the Scheduler in Combine 
- Scheduleris a protocol in the Combine framework, and it represents a type that can describe when and how to execute a closure. Many types that ship with Foundation conform to this protocol because they can also schedule work, for example- DispatchQueue,- RunLoopand- OperationQueue.
DispatchQueue.main.schedule {
  print("DispatchQueue")
}
DispatchQueue.main.schedule(after: .init(.now() + 1)) {
  print("DispatchQueue", "delayed")
}
- We can use .asyncAfterinstead of.schedule, btw
var cancellables: Set<AnyCancellable> = []
OperationQueue.main.schedule(after: .init(Date()), interval: 1) {
  print("OperationQueue", "timer")
}.store(in: &cancellables)
DispatchQueue -> now() RunLoop -> Date() OperationQueue -> Date()
- 
    The Schedulerprotocol is the way Combine abstracts away the responsibility of when and how to execute a unit of work. Any Combine operator that involves time or threading takes a scheduler as an argument, including delaying, throttling, timeouts, debouncing, and more:
- Here is the operators that have the Scheduler as a parameter.
    Just(1) .receive(on: Scheduler) .subscribe(on: Scheduler) .timeout(SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, scheduler: Scheduler) .throttle(for: SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, scheduler: Scheduler, latest: Bool) .debounce(for: SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, scheduler: Scheduler) .delay(for: SchedulerTimeIntervalConvertible & Comparable & SignedNumeric, scheduler: Scheduler)
- Anytime we use a DispatchQueue,RunLooporOperationQueuewe are hopelessly in the realm of the real world, with all of its complexities and vagaries, and we have no way to control the scheduler except for literally waiting for time to pass.
TestScheduler
let scheduler = DispatchQueue.testScheduler
scheduler.schedule {
  print("TestScheduler")
}
- Nothing will be printed until the code below is added.
scheduler.advance()
- The Upper code will be changed too.
- Previous
    scheduler.schedule(after: init(.now() + 1)) { print("TestScheduler", "delayed") }
- Present
    scheduler.schedule(after: scheduler.now.advanced(by: 1)) { print("TestScheduler", "delayed") }
- We can control the time like this:
scheduler.advance(by: 1)
- We can do it in Timer too
scheduler.schedule(after: scheduler.now, interval: 1) {
  print("TestScheduler", "delayed")
}
scheduler.advance()
scheduler.advance(by: 1000) // the 1000 lines will be printed
- the use of DispatchQueue.mainin our reducer is an unintended side-effect, and it must be controlled. This means we need to move it to be a dependency in our environment just like theUUIDfunction.
struct AppEnvironment {
  var mainQueue: ???
  var uuid: () -> UUID
}
- ??? wouldn't be DispatchQueue- Because then we couldn’t substitute in a test scheduler when we are in tests.
 
- ??? wouldn't be Scheduler- because the protocol has associated types.
        🛑 Protocol ‘Scheduler’ can only be used as a generic constraint because it has Self or associated type requirements 
 
- because the protocol has associated types.
        
- ??? wouldn't be like this:
    var mainQueue: Scheduler where .SchedulerTimeType == DispatchQueue.SchedulerTimeType, .SchedulerOptions == DispatchQueue.SchedulerOptions
- 
    until now,,,, 
- so we have to turn to a more ad hoc solution.
- We need one of those “Any” type erasers for the Schedulerprotocol. The standard library and various frameworks from Apple has multiple type erased wrappers, such as:// AnyHashable // AnyIterator // AnyCollection // AnySubscriber // AnyCancellable // AnyPublisher // AnyView
- You use them in places where you’d like to be able to use the bare protocol.
var mainQueue: AnyScheduler<DispatchQueue.SchedulerTimeType, DispatchQueue.SchedulerOptions>
- this code is too verbose, so
var mainQueue: AnySchedulerOf<DispatchQueue>
case .todo(index: \_, action: .checkboxTapped):
	struct CancelDelayId: Hashable {}
	return Effect(value: AppAction.todoDelayCompleted)
		.debounce(id: CancelDelayId(), for: 1, scheduler: environment.mainQueue)
- We need to match the type, so we use .eraseToAnyScheduler()in the real App
environment: AppEnvironment(
	mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
	uuid: UUID.init
)
- We will use the different Scheduler in the Test Suite.
environment: AppEnvironment(
	mainQueue: DispatchQueue.testScheduler.eraseToAnyScheduler(),
	uuid: { fatalError("unimplemented") }
)
- But when we use .eraseToAnyScheduler(), this erase all the info that we had that identified it as aTestScheduler.- We don't have access to anything that allows us need to advance time on this test scheduler.
 
- So we store this in our scheduler variable.
let scheduler = DispatchQueue.testScheduler
//_ = XCTWaiter.wait(for: [self.expectation(description: "wait")], timeout: 0.5)
self.scheduler.advance(by: 0.5)
- Now, Check the test suite time!
Test Suite 'TCA_ToDoListTests' passed at 2023-03-10 16:26:37.384. Executed 4 tests, with 0 failures (0 unexpected) in 0.036 (0.042) seconds