diff --git a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift index 8d65517164..88fe0a4b6e 100644 --- a/TableProTests/Core/SSH/SSHTunnelErrorTests.swift +++ b/TableProTests/Core/SSH/SSHTunnelErrorTests.swift @@ -141,22 +141,28 @@ struct SSHTunnelErrorTests { #expect(interrupted.withLock { $0 }) } + /// The dismissal is a main-actor job and this test runs off the main actor, so it waits for + /// the dismissal itself. Yielding here never waited for the main actor, and every yield could + /// pass before the main thread took its turn. @Test("Cancelling SSH authentication dismisses its active prompt") func cancellationDismissesPrompt() async throws { let deadline = ConnectionDeadline(configuredSeconds: 30) let endpoint = ConnectionTimeoutEndpoint.tunnel("jump.example:22") let attempt = SSHConnectionAttempt(deadline: deadline, endpoint: endpoint) - let dismissed = OSAllocatedUnfairLock(initialState: false) + let (dismissals, dismissal) = AsyncStream.makeStream() let promptId = try attempt.registerPrompt(for: endpoint) { - dismissed.withLock { $0 = true } + dismissal.yield() + dismissal.finish() } attempt.cancel() - for _ in 0..<20 where !dismissed.withLock({ $0 }) { - await Task.yield() + + let dismissed = await BoundedCall.result { + for await _ in dismissals { return true } + return false } - #expect(dismissed.withLock { $0 }) + #expect(dismissed == true) #expect(throws: CancellationError.self) { try attempt.check(for: endpoint) } diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index 47f8471a1b..f3b49f511f 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -6,6 +6,7 @@ // import AppKit +import Combine import Foundation import SwiftUI import Testing @@ -521,6 +522,34 @@ struct SidebarViewModelMultiSectionTests { } } +/// The debounce runs on real time, so a test waits for the value it publishes and not for a +/// sleep of its own to outlast it. `$filterQuery` replays its current value to a new subscriber, +/// which `dropFirst` discards. +@MainActor +private final class DebouncedFilterQueries { + private(set) var published: [String] = [] + private let values: AsyncStream + private var subscription: AnyCancellable? + + init(of viewModel: SidebarViewModel) { + let (values, continuation) = AsyncStream.makeStream() + self.values = values + subscription = viewModel.$filterQuery.dropFirst().sink { [weak self] value in + self?.published.append(value) + continuation.yield(value) + } + } + + func first() async -> String? { + let stream = values + let arrived = await BoundedCall.result { () -> String? in + for await value in stream { return value } + return nil + } + return arrived ?? nil + } +} + struct SidebarViewModelSearchDebounceTests { @Test("filterQuery updates immediately on first non-empty input") @MainActor @@ -563,12 +592,11 @@ struct SidebarViewModelSearchDebounceTests { func filterQueryCatchesUpAfterDebounce() async { let vm = makeViewModel() vm.searchText = "user" + let debounced = DebouncedFilterQueries(of: vm) vm.searchText = "users" - try? await Task.sleep(nanoseconds: 300_000_000) - await Task.yield() - + #expect(await debounced.first() == "users") #expect(vm.filterQuery == "users") } @@ -578,6 +606,8 @@ struct SidebarViewModelSearchDebounceTests { let vm = makeViewModel() vm.searchText = "u" + let debounced = DebouncedFilterQueries(of: vm) + vm.searchText = "us" vm.searchText = "use" vm.searchText = "user" @@ -585,9 +615,8 @@ struct SidebarViewModelSearchDebounceTests { #expect(vm.filterQuery == "u") - try? await Task.sleep(nanoseconds: 300_000_000) - await Task.yield() - + #expect(await debounced.first() == "user") + #expect(debounced.published == ["user"]) #expect(vm.filterQuery == "user") }