Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
16 changes: 11 additions & 5 deletions TableProTests/Core/SSH/SSHTunnelErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void>.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)
}
Expand Down
41 changes: 35 additions & 6 deletions TableProTests/ViewModels/SidebarViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import AppKit
import Combine
import Foundation
import SwiftUI
import Testing
Expand Down Expand Up @@ -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<String>
private var subscription: AnyCancellable?

init(of viewModel: SidebarViewModel) {
let (values, continuation) = AsyncStream<String>.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
Expand Down Expand Up @@ -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")
}

Expand All @@ -578,16 +606,17 @@ struct SidebarViewModelSearchDebounceTests {
let vm = makeViewModel()
vm.searchText = "u"

let debounced = DebouncedFilterQueries(of: vm)

vm.searchText = "us"
vm.searchText = "use"
vm.searchText = "user"
await Task.yield()

#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")
}

Expand Down
Loading