Files
Termind/TFITests/MobileConnectionPickerTests.swift
T
2026-09-19 09:13:53 +08:00

232 lines
9.5 KiB
Swift

import Foundation
import SwiftUI
import TermindDomain
import Testing
import UIKit
import struct TermindDomain.Host
@testable import TermindIOS
@Suite(.serialized) @MainActor
struct MobileConnectionPickerTests {
@Test func pickerDefaultsToSSHFiltersHostsAndReturnsTheSelectedProtocol() throws {
let app = try MobileAppModel(inMemory: true)
var selections: [(Host.ID, ConnectionHistoryEntry.Kind)] = []
let hostModel = MobileHostPickerModel(library: app.library)
let connectionModel = MobileConnectionPickerModel { selections.append(($0, $1)) }
#expect(connectionModel.kind == .ssh)
#expect(hostModel.isLibraryEmpty)
let production = Host(name: "Production", host: "production.example.invalid")
let staging = Host(name: "Staging", host: "staging.example.invalid")
app.library.upsert(production)
app.library.upsert(staging)
#expect(!hostModel.isLibraryEmpty)
#expect(Set(hostModel.hosts.map(\.id)) == Set([production.id, staging.id]))
hostModel.query = "prod"
#expect(hostModel.hosts.map(\.id) == [production.id])
connectionModel.select(production)
#expect(selections.count == 1)
#expect(selections.first?.0 == production.id)
#expect(selections.first?.1 == .ssh)
connectionModel.kind = .sftp
connectionModel.select(staging)
#expect(selections.count == 2)
#expect(selections.last?.0 == staging.id)
#expect(selections.last?.1 == .sftp)
}
@Test func emptyWorkspaceHistoryKeepsTheLatestFiveUniqueHostAndProtocolPairs() throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app)
let repeatedHost = Host(name: "Repeated", host: "repeated.example.invalid")
let otherHosts = (0..<4).map {
Host(name: "Host \($0)", host: "host-\($0).example.invalid")
}
app.library.upsert(repeatedHost)
for host in otherHosts { app.library.upsert(host) }
let newestSSH = try #require(
app.connectionHistory.record(kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 70)))
_ = app.connectionHistory.record(
kind: .ssh, for: repeatedHost.id, at: Date(timeIntervalSince1970: 60))
let newestSFTP = try #require(
app.connectionHistory.record(kind: .sftp, for: repeatedHost.id, at: Date(timeIntervalSince1970: 55)))
let expectedEntries = try [
newestSSH,
newestSFTP,
#require(
app.connectionHistory.record(kind: .sftp, for: otherHosts[0].id, at: Date(timeIntervalSince1970: 50))),
#require(
app.connectionHistory.record(kind: .ssh, for: otherHosts[1].id, at: Date(timeIntervalSince1970: 40))),
#require(
app.connectionHistory.record(kind: .sftp, for: otherHosts[2].id, at: Date(timeIntervalSince1970: 30))),
]
_ = app.connectionHistory.record(
kind: .ssh, for: otherHosts[3].id, at: Date(timeIntervalSince1970: 20))
_ = app.connectionHistory.record(
kind: .ssh, for: UUID(), at: Date(timeIntervalSince1970: 80))
let rows = MobileWorkspaceListView(app: app, scene: scene).historyRows
#expect(rows.count == MobileWorkspaceListView.historyLimit)
#expect(rows.map(\.entry.id) == expectedEntries.map(\.id))
#expect(rows[0].entry.hostID == rows[1].entry.hostID)
#expect(rows[0].entry.kind != rows[1].entry.kind)
#expect(Set(rows.map { "\($0.entry.hostID.uuidString):\($0.entry.kind.rawValue)" }).count == rows.count)
}
@Test func snippetHostPickerGatesConfirmationOnRequiredSelection() async throws {
let app = try MobileAppModel(inMemory: true)
let host = Host(name: "Production", host: "production.example.invalid")
app.library.upsert(host)
try await withStandaloneNavigation(
MobileSnippetHostPicker(
app: app, selected: [], title: "Execute on Hosts", actionTitle: "Execute",
requiresSelection: true, save: { _ in }),
language: "en"
) { navigation in
let execute = try #require(toolbarButton(named: "Execute", in: navigation))
#expect(!execute.isEnabled)
}
try await withStandaloneNavigation(
MobileSnippetHostPicker(
app: app, selected: [host.id], title: "Add Hosts", actionTitle: "Done",
requiresSelection: false, save: { _ in }),
language: "en"
) { navigation in
let done = try #require(toolbarButton(named: "Done", in: navigation))
#expect(done.isEnabled)
}
}
@Test(
arguments: [
("en", "Connect", "More"),
("zh-Hans", "连接", "更多"),
("zh-Hant", "連線", "更多"),
])
func workspaceToolbarExposesLocalizedActions(
language: String, expectedConnectName: String, expectedMoreName: String
) async throws {
var connectActivations = 0
try await withNavigation(
ToolbarHost(
hasWorkspaces: true,
closeAll: {},
connect: { connectActivations += 1 }),
language: language
) { navigation in
let connect = try #require(toolbarButton(named: expectedConnectName, in: navigation))
let connectAction = try #require(connect.action)
#expect(UIApplication.shared.sendAction(connectAction, to: connect.target, from: connect, for: nil))
#expect(connectActivations == 1)
if #available(iOS 27.0, *) {
#expect(navigation.topViewController?.navigationItem.additionalOverflowItems != nil)
} else {
let more = try #require(toolbarButton(named: expectedMoreName, in: navigation))
#expect(more.isEnabled)
}
}
}
private func withNavigation<Content: View>(
_ content: Content,
language: String,
check: (UINavigationController) throws -> Void
) async throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previousWindow = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(
rootView: NavigationStack { content }.environment(\.locale, Locale(identifier: language)))
window.rootViewController = controller
window.windowLevel = .alert + 1
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previousWindow?.makeKey()
}
try await Task.sleep(for: .milliseconds(700))
let navigation = try #require(descendant(of: UINavigationController.self, in: controller))
try check(navigation)
}
private func withStandaloneNavigation<Content: View>(
_ content: Content,
language: String,
check: (UINavigationController) throws -> Void
) async throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previousWindow = scene.keyWindow
let window = UIWindow(windowScene: scene)
let controller = UIHostingController(
rootView: content.environment(\.locale, Locale(identifier: language)))
window.rootViewController = controller
window.windowLevel = .alert + 1
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
previousWindow?.makeKey()
}
try await Task.sleep(for: .milliseconds(700))
let navigation = try #require(descendant(of: UINavigationController.self, in: controller))
try check(navigation)
}
private func toolbarButton(named name: String, in navigation: UINavigationController) -> UIBarButtonItem? {
guard let item = navigation.topViewController?.navigationItem else { return nil }
let buttons =
item.leadingItemGroups.flatMap(\.barButtonItems)
+ item.trailingItemGroups.flatMap(\.barButtonItems)
+ (item.leftBarButtonItems ?? [])
+ (item.rightBarButtonItems ?? [])
return buttons.first { button in
button.accessibilityLabel == name || button.title == name || button.customView?.accessibilityLabel == name
}
}
private func descendant<T: UIViewController>(of type: T.Type, in controller: UIViewController) -> T? {
var pending = [controller]
var visited = Set<ObjectIdentifier>()
while let next = pending.popLast() {
guard visited.insert(ObjectIdentifier(next)).inserted else { continue }
if let result = next as? T { return result }
pending.append(contentsOf: next.children)
if let presented = next.presentedViewController { pending.append(presented) }
}
return nil
}
}
private struct ToolbarHost: View {
let hasWorkspaces: Bool
let closeAll: () -> Void
let connect: () -> Void
var body: some View {
Color.clear
.navigationTitle("Workspaces")
.toolbar {
MobileWorkspaceToolbar(
hasWorkspaces: hasWorkspaces,
hasHistory: false,
closeAll: closeAll,
clearHistory: {},
connect: connect
)
}
}
}