Files
Termind/TFITests/MobileWorkspaceMenuTests.swift
T
Kaede Akino 82d871c4fe refactor: separate feature policies and UI workflows
Move connection policy out of ProAccess, keep terminal uploads with their
session, and share question interaction and highlight editing operations.
Consolidate CPU usage and settings snapshot handling, and remove obsolete
terminal selection, theme helpers, and hard-coded latest-schema assertions.

Fix draining connection-pool recovery and stale pane upload bindings. Keep
screenshot matrices opt-in and retain behavioral migration coverage.

Validation: 2468 macOS tests passed serially, 31 iOS tests passed, and both
SSH forwarding regressions passed against the local OpenSSH fixture.
Both app builds, Swift formatting, AppStrings, and Chinese localization
checks passed. Parallel test runs still exhibit intermittent timeouts.
2026-10-02 16:40:27 +08:00

199 lines
9.0 KiB
Swift

import SwiftUI
import TermindApplication
import Testing
import UIKit
@testable import TermindIOS
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_MOBILE_UI_TESTS"] == "1"), .serialized) @MainActor
struct MobileWorkspaceMenuTests {
@Test(arguments: [false, true])
func switcherIncludesOpenedAndDormantWorkspaceDescriptorsInOrder(iconSwitcher: Bool) async throws {
let app = try MobileAppModel(inMemory: true)
let opened = MobileWorkspaceDescriptor(hostID: UUID(), title: "Opened", kind: .ssh)
let dormant = MobileWorkspaceDescriptor(hostID: UUID(), title: "Dormant", kind: .sftp)
let workspace = MobileWorkspace(descriptor: opened, hosts: app.library)
defer { workspace.disconnect() }
try await withNavigation(
MobileWorkspaceView(
library: app.library, commandGenerator: app.commandGenerator,
workspace: workspace, workspaces: [opened, dormant],
unreadWorkspaceIDs: [dormant.id],
selectWorkspace: { _ in }, renameWorkspace: { _ in }, canCloseMultipleWorkspaces: true,
openSameDirectory: { _ in }, closeWorkspace: { _ in }, closeOtherWorkspaces: { _ in }
)
.environment(\.mobileToolbarLayout, MobileToolbarLayout(isTopBar: iconSwitcher))
) { navigation in
let source =
iconSwitcher
? toolbarButton(named: "Workspaces", in: navigation)?.menu
: navigation.topViewController?.navigationItem.titleMenuProvider?([])
let menu = try await resolvedMenu(#require(source), in: navigation.view)
let actions = menuActions(menu)
#expect(actions.map(\.title) == ["Opened", "Dormant"])
#expect(try #require(actions[0].image).isSymbolImage)
#expect(try #require(actions[1].image).isSymbolImage == false)
}
}
@Test(arguments: [false, true])
func assistantSwitcherRemovesTheUnreadIconWhenTheConversationIsRead(iconSwitcher: Bool) async throws {
let app = try MobileAppModel(inMemory: true)
let scene = MobileSceneModel(app: app, sessionID: "notification-menu")
await scene.assistant.load()
let first = ConversationModel()
first.appendUserTurn("Current conversation")
let second = ConversationModel()
second.appendUserTurn("Unread conversation")
scene.assistant.library.insertAndSelect(second)
scene.assistant.library.insertAndSelect(first)
second.markNotificationsUnread()
try await withNavigation(
MobileAssistantView(app: app, model: scene.assistant, libraryRoute: .constant(nil))
.environment(\.mobileToolbarLayout, MobileToolbarLayout(isTopBar: iconSwitcher))
) { navigation in
for unread in [true, false] {
if !unread { second.markNotificationsRead() }
try await Task.sleep(for: .milliseconds(350))
let source =
iconSwitcher
? toolbarButton(named: "Conversations", in: navigation)?.menu
: navigation.topViewController?.navigationItem.titleMenuProvider?([])
let menu = try await resolvedMenu(#require(source), in: navigation.view)
let action = try #require(menuActions(menu).first { $0.title == second.derivedTitle })
#expect(try #require(action.image).isSymbolImage == !unread)
}
}
}
@Test(arguments: [false, true])
func lifecycleActionsOnlyShowBatchClosingWhenMultipleWorkspacesCanClose(
canCloseMultipleWorkspaces: Bool
) async throws {
try await withNavigation(
LifecycleMenuHost(canCloseMultipleWorkspaces: canCloseMultipleWorkspaces)
) { navigation in
let button = try #require(toolbarButton(named: "Workspace Actions", in: navigation))
let menu = try await resolvedMenu(#require(button.menu), in: navigation.view)
let actions = menuActions(menu)
let expected =
canCloseMultipleWorkspaces
? ["Rename", "Close", "Close Others"]
: ["Rename", "Close"]
#expect(actions.map(\.title) == expected)
#expect(!actions[0].attributes.contains(.destructive))
#expect(actions.dropFirst().allSatisfy { $0.attributes.contains(.destructive) })
#expect(actions.contains { $0.title == "Close All" } == false)
if canCloseMultipleWorkspaces {
let closeOthers = try #require(actions.last)
#expect(closeOthers.image == UIImage(systemName: "xmark"))
}
let separatedActions = menu.children.compactMap { $0 as? UIMenu }
.filter { $0.options.contains(.displayInline) }
.map { menuActions($0).map(\.title) }
#expect(separatedActions == (canCloseMultipleWorkspaces ? [["Close Others"]] : []))
}
}
private func withNavigation<Content: View>(
_ content: Content,
check: (UINavigationController) async 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 })
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 await check(navigation)
}
private func resolvedMenu(_ menu: UIMenu, in view: UIView) async throws -> UIMenu {
let button = UIButton(frame: CGRect(x: 100, y: 100, width: 44, height: 44))
button.menu = menu
button.showsMenuAsPrimaryAction = true
view.addSubview(button)
defer { button.removeFromSuperview() }
button.performPrimaryAction()
try await Task.sleep(for: .milliseconds(500))
let interaction = try #require(button.contextMenuInteraction)
var resolvedMenu: UIMenu?
interaction.updateVisibleMenu { resolved in
resolvedMenu = resolved
return resolved
}
if let window = view.window {
let image = UIGraphicsImageRenderer(bounds: window.bounds).pngData { _ in
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
}
Attachment.record(image, named: "workspace-menu.png")
}
interaction.dismissMenu()
try await Task.sleep(for: .milliseconds(300))
return try #require(resolvedMenu)
}
private func menuActions(_ menu: UIMenu) -> [UIAction] {
menu.children.flatMap { element -> [UIAction] in
if let action = element as? UIAction { return [action] }
if let submenu = element as? UIMenu { return menuActions(submenu) }
return []
}
}
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 {
$0.accessibilityLabel == name || $0.title == name || $0.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 LifecycleMenuHost: View {
let canCloseMultipleWorkspaces: Bool
var body: some View {
Color.clear
.navigationTitle("Workspace")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
MobileWorkspaceLifecycleMenuActions(
canCloseMultipleWorkspaces: canCloseMultipleWorkspaces,
rename: {}, close: {}, closeOthers: {})
} label: {
Label("Workspace Actions", systemImage: "ellipsis")
}
.labelStyle(.iconOnly)
.menuOrder(.fixed)
}
}
}
}