205 lines
9.2 KiB
Swift
205 lines
9.2 KiB
Swift
import SwiftUI
|
|
import TermindApplication
|
|
import Testing
|
|
import UIKit
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite(.requiresKeyWindow, .serialized) @MainActor
|
|
struct MobileWorkspaceMenuTests {
|
|
@Test(arguments: [false, true], [HorizontalEdge?.none, .leading, .trailing])
|
|
func switcherIncludesOpenedAndDormantWorkspaceDescriptorsInOrder(
|
|
isTopBar: Bool, verticalEdge: HorizontalEdge?
|
|
) async throws {
|
|
let iconSwitcher = isTopBar
|
|
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: isTopBar, verticalEdge: verticalEdge))
|
|
) { 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], [HorizontalEdge?.none, .leading, .trailing])
|
|
func assistantSwitcherRemovesTheUnreadIconWhenTheConversationIsRead(
|
|
isTopBar: Bool, verticalEdge: HorizontalEdge?
|
|
) async throws {
|
|
let iconSwitcher = isTopBar
|
|
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: isTopBar, verticalEdge: verticalEdge))
|
|
) { 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)
|
|
}
|
|
}
|
|
}
|
|
}
|