857 lines
46 KiB
Swift
857 lines
46 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import ListViewKit
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
import UIKit
|
|
|
|
@testable import TermindIOS
|
|
|
|
@Suite(.serialized) @MainActor
|
|
struct MobileAdaptiveLayoutTests {
|
|
@Test(.requiresKeyWindow) func metricsRetainsItsNativeListAndScrollPositionAcrossFoldsAndVerticalMenuChanges()
|
|
async throws
|
|
{
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.selectedDestination = .metrics
|
|
let server = ServerMetric(selection: MetricsSelection(id: UUID(), enabled: false), name: "Fold Metrics")
|
|
server.processes = (1...100).map {
|
|
MetricsProcess(pid: $0, started: 1, name: "process-\($0)", cpu: Double($0), memory: Double(101 - $0))
|
|
}
|
|
let regions = MobileRegionTestState()
|
|
var features = MobileFeatureRegistry()
|
|
features.register(
|
|
MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in
|
|
AnyView(
|
|
MobileRegionTestHost(state: regions) {
|
|
MobileMetricsDetailView(app: app, server: server)
|
|
.environment(\.mobileToolbarLayout, regions.toolbarLayout)
|
|
})
|
|
})
|
|
try await withRoot(app: app, scene: scene, features: features, initialSizeClass: .compact) { controller in
|
|
let navigation = try #require(navigation(titled: server.name, in: controller))
|
|
let picker = try #require(find(UISegmentedControl.self, in: controller.view))
|
|
picker.selectedSegmentIndex = 1
|
|
picker.sendActions(for: .valueChanged)
|
|
try await settle()
|
|
let list = try #require(find(UICollectionView.self, in: navigation.view))
|
|
list.setContentOffset(CGPoint(x: 0, y: 350), animated: false)
|
|
try await settle()
|
|
for region in [
|
|
MobileReservedRegion(kind: .division, frame: CGRect(x: 0, y: 280, width: 2000, height: 24)),
|
|
MobileReservedRegion(kind: .division, frame: CGRect(x: 200, y: 0, width: 24, height: 2000)),
|
|
MobileReservedRegion(kind: .division, frame: .zero, isActive: false),
|
|
] {
|
|
regions.regions = [region]
|
|
try await settle()
|
|
#expect(find(UICollectionView.self, in: navigation.view) === list)
|
|
#expect(list.contentOffset.y > 200)
|
|
if region.isActive { #expect(find(UISegmentedControl.self, in: controller.view) == nil) }
|
|
}
|
|
regions.toolbarLayout.verticalEdge = .trailing
|
|
try await settle()
|
|
#expect(find(UISegmentedControl.self, in: controller.view) == nil)
|
|
let menuButton = try #require(toolbarButton(namedOneOf: ["Details"], in: navigation))
|
|
var actions = try await resolvedMenuActions(#require(menuButton.menu), in: navigation.view)
|
|
#expect(actions.contains { $0.title == "Processes" && $0.state == .on && $0.image != nil })
|
|
let status = try #require(actions.first { $0.title == "Status" })
|
|
UIButton(primaryAction: status).sendActions(for: .primaryActionTriggered)
|
|
try await settle()
|
|
#expect(find(UICollectionView.self, in: navigation.view) === list)
|
|
let updatedButton = try #require(toolbarButton(namedOneOf: ["Details"], in: navigation))
|
|
actions = try await resolvedMenuActions(#require(updatedButton.menu), in: navigation.view)
|
|
#expect(actions.contains { $0.title == "Status" && $0.state == .on })
|
|
let processes = try #require(actions.first { $0.title == "Processes" })
|
|
UIButton(primaryAction: processes).sendActions(for: .primaryActionTriggered)
|
|
try await settle()
|
|
#expect(find(UICollectionView.self, in: navigation.view) === list)
|
|
#expect(list.contentOffset.y > 200)
|
|
regions.toolbarLayout.verticalEdge = nil
|
|
try await settle()
|
|
#expect(try #require(find(UISegmentedControl.self, in: controller.view)).selectedSegmentIndex == 1)
|
|
#expect(toolbarButton(namedOneOf: ["Details"], in: navigation) == nil)
|
|
#expect(find(UICollectionView.self, in: navigation.view) === list)
|
|
#expect(list.contentOffset.y > 200)
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
|
|
func foldingKeepsAssistantTranscriptComposerFocusAndMarkedText(language: String) async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.selectedDestination = .assistant
|
|
await scene.assistant.load()
|
|
let conversation = scene.assistant.conversation
|
|
conversation.appendUserTurn("Keep this transcript while folding")
|
|
conversation.composer.draft = "Draft across folds"
|
|
let regions = MobileRegionTestState()
|
|
try await withRoot(app: app, scene: scene, language: language, regions: regions) { controller in
|
|
let composer = try #require(textInput(containing: conversation.composer.draft, in: controller.view))
|
|
let input = try #require(composer as? any UITextInput)
|
|
let transcript = try #require(find(ListScrollView.self, in: controller.view))
|
|
#expect(composer.becomeFirstResponder())
|
|
input.setMarkedText("输入中", selectedRange: NSRange(location: 3, length: 0))
|
|
try await settle()
|
|
for (index, region) in [
|
|
MobileReservedRegion(kind: .division, frame: CGRect(x: 0, y: 160, width: 2000, height: 24)),
|
|
MobileReservedRegion(kind: .division, frame: CGRect(x: 350, y: 0, width: 24, height: 2000)),
|
|
MobileReservedRegion(kind: .occlusion, frame: CGRect(x: 0, y: 0, width: 40, height: 40)),
|
|
MobileReservedRegion(kind: .division, frame: .zero, isActive: false),
|
|
].enumerated() {
|
|
regions.regions = [region]
|
|
try await settle()
|
|
#expect(scene.assistant.conversation === conversation)
|
|
#expect(find(ListScrollView.self, in: controller.view) === transcript)
|
|
#expect(composer.isFirstResponder)
|
|
#expect(composer.window != nil)
|
|
let marked = try #require(input.markedTextRange)
|
|
#expect(input.text(in: marked) == "输入中")
|
|
capture(controller, name: "assistant-fold-\(index)-\(language)")
|
|
}
|
|
for isRunning in [true, false] {
|
|
conversation.handle(.runState(isRunning ? .running : .completed))
|
|
try await settle()
|
|
#expect(find(ListScrollView.self, in: controller.view) === transcript)
|
|
#expect(composer.isFirstResponder)
|
|
#expect(composer.window != nil)
|
|
let marked = try #require(input.markedTextRange)
|
|
#expect(input.text(in: marked) == "输入中")
|
|
capture(controller, name: "assistant-run-\(isRunning)-\(language)")
|
|
}
|
|
input.unmarkText()
|
|
composer.resignFirstResponder()
|
|
}
|
|
}
|
|
|
|
private func textInput(containing text: String, in view: UIView) -> UIView? {
|
|
if let input = view as? any UITextInput,
|
|
let range = input.textRange(from: input.beginningOfDocument, to: input.endOfDocument),
|
|
input.text(in: range) == text
|
|
{
|
|
return view
|
|
}
|
|
return view.subviews.lazy.compactMap { textInput(containing: text, in: $0) }.first
|
|
}
|
|
|
|
@Test func widthClassificationDefaultsToCompact() {
|
|
#expect(MobileLayout(sizeClass: nil) == .compact)
|
|
#expect(MobileLayout(sizeClass: .compact) == .compact)
|
|
#expect(MobileLayout(sizeClass: .regular) == .regular)
|
|
}
|
|
|
|
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"], [DynamicTypeSize.large, .accessibility3])
|
|
func fiveNativeTabsRemainAvailableAcrossWindowWidths(language: String, typeSize: DynamicTypeSize) async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
try await withRoot(app: app, scene: scene, language: language, typeSize: typeSize) { controller in
|
|
let tabs = try #require(find(UITabBarController.self, in: controller))
|
|
#expect(tabs.tabs.count == 5)
|
|
#expect(tabs.mode == .tabSidebar)
|
|
for (width, sizeClass): (CGFloat, UIUserInterfaceSizeClass) in [
|
|
(1024, .regular), (600, .regular), (375, .compact), (320, .compact), (1024, .regular),
|
|
] {
|
|
resize(controller, to: sizeClass, width: width)
|
|
tabs.sidebar.isHidden = true
|
|
try await settle()
|
|
#expect(find(UITabBarController.self, in: controller) === tabs)
|
|
let pages = tabs.tabs
|
|
#expect(pages.count == 5)
|
|
for (index, destination) in MobileDestination.allCases.enumerated() {
|
|
let page = try #require(pages.indices.contains(index) ? pages[index] : nil)
|
|
let previous = tabs.selectedTab
|
|
tabs.selectedTab = page
|
|
tabs.delegate?.tabBarController?(tabs, didSelectTab: page, previousTab: previous)
|
|
try await settle()
|
|
#expect(scene.selectedDestination == destination)
|
|
#expect(tabs.selectedTab === page)
|
|
let title = page.title
|
|
#expect(visibleLabels(in: tabs.view).contains { $0.text == title })
|
|
}
|
|
let items = tabs.tabs
|
|
#expect(items.count == 5)
|
|
capture(controller, name: "tabs-\(Int(width))-\(language)-\(typeSize)")
|
|
let labels = visibleLabels(in: tabs.view)
|
|
Attachment.record(
|
|
labels.map { "\($0.text ?? ""): \($0.bounds), natural \($0.intrinsicContentSize)" }
|
|
.joined(separator: "\n"), named: "tab-labels-\(Int(width))-\(language)-\(typeSize)")
|
|
// The regular-width iPad bar scrolls when its titles do not fit.
|
|
// Compact width puts all five destinations in the bottom bar.
|
|
if sizeClass == .compact {
|
|
for item in items {
|
|
#expect(
|
|
labels.contains { $0.text == item.title }, "Missing visible tab: \(item.title)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow) func compactLaunchKeepsTheConversationAndDraftAcrossResizing() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.selectedDestination = .assistant
|
|
let conversation = scene.assistant.conversation
|
|
conversation.composer.draft = "Keep this draft through rotation"
|
|
try await withRoot(app: app, scene: scene, initialSizeClass: .compact) { controller in
|
|
let navigation = try #require(navigation(titled: "Assistant", in: controller))
|
|
let detail = navigation.topViewController
|
|
for sizeClass: UIUserInterfaceSizeClass in [.regular, .compact, .regular] {
|
|
resize(controller, to: sizeClass)
|
|
try await settle()
|
|
#expect(scene.selectedDestination == .assistant)
|
|
#expect(scene.assistant.conversation === conversation)
|
|
#expect(conversation.composer.draft == "Keep this draft through rotation")
|
|
#expect(navigation.topViewController === detail)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow) func metricsStaysSelectedAcrossResizingAndTabRoundTrips() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.selectedDestination = .metrics
|
|
try await withRoot(app: app, scene: scene) { controller in
|
|
let metrics = try #require(navigation(titled: "Metrics", in: controller))
|
|
let page = metrics.topViewController
|
|
for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular, .compact] {
|
|
resize(controller, to: sizeClass)
|
|
try await settle()
|
|
#expect(scene.selectedDestination == .metrics)
|
|
#expect(navigation(titled: "Metrics", in: controller) === metrics)
|
|
#expect(metrics.topViewController === page)
|
|
}
|
|
scene.openSettings()
|
|
try await settle()
|
|
scene.selectedDestination = .metrics
|
|
try await settle()
|
|
#expect(navigation(titled: "Metrics", in: controller) === metrics)
|
|
#expect(metrics.topViewController === page)
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
|
|
func assistantSwitchersReflectAvailableConversationsAcrossToolbarLayouts(language: String) async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.selectedDestination = .assistant
|
|
await scene.assistant.load()
|
|
let conversation = scene.assistant.conversation
|
|
conversation.composer.draft = "Keep the draft while switchers update"
|
|
let regions = MobileRegionTestState()
|
|
var features = MobileFeatureRegistry()
|
|
features.register(
|
|
MobileFeatureRegistration(id: .assistant, title: "Assistant", symbol: "sparkles") { app, scene in
|
|
AnyView(
|
|
MobileAssistantView(
|
|
app: app, model: scene.assistant,
|
|
libraryRoute: Binding(
|
|
get: { scene.assistantLibraryRoute }, set: { scene.assistantLibraryRoute = $0 })
|
|
)
|
|
.environment(\.mobileToolbarLayout, regions.toolbarLayout))
|
|
})
|
|
try await withRoot(app: app, scene: scene, language: language, features: features, initialSizeClass: .compact) {
|
|
controller in
|
|
let navigation = try #require(navigation(titled: "Assistant", in: controller))
|
|
let page = try #require(navigation.topViewController)
|
|
for (state, enabled) in [
|
|
("empty", false), ("current", true), ("pinned", true), ("archived", false), ("restored", true),
|
|
] {
|
|
switch state {
|
|
case "current":
|
|
conversation.appendUserTurn("The only conversation")
|
|
scene.assistant.library.insertAndSelect(conversation)
|
|
case "pinned": conversation.isPinned = true
|
|
case "archived":
|
|
conversation.isPinned = false
|
|
conversation.archivedAt = .now
|
|
case "restored": conversation.archivedAt = nil
|
|
default: break
|
|
}
|
|
// Check a data-only update before changing toolbar placement.
|
|
try await settle()
|
|
try expectAssistantSwitcher(in: navigation, layout: regions.toolbarLayout, enabled: enabled)
|
|
for (name, layout) in [
|
|
("title", MobileToolbarLayout()),
|
|
("top", MobileToolbarLayout(isTopBar: true)),
|
|
("vertical", MobileToolbarLayout(verticalEdge: .trailing)),
|
|
] {
|
|
regions.toolbarLayout = layout
|
|
try await settle()
|
|
try expectAssistantSwitcher(in: navigation, layout: layout, enabled: enabled)
|
|
#expect(navigation.topViewController === page)
|
|
#expect(scene.assistant.conversation === conversation)
|
|
#expect(conversation.composer.draft == "Keep the draft while switchers update")
|
|
let newConversation = try #require(
|
|
toolbarButton(namedOneOf: ["New Conversation", "新建对话", "新增對話"], in: navigation))
|
|
#expect(newConversation.isEnabled)
|
|
capture(controller, name: "assistant-switcher-\(state)-\(name)-\(language)")
|
|
}
|
|
if !enabled {
|
|
let menu: UIMenu
|
|
if #available(iOS 27.0, *) {
|
|
menu = UIMenu(children: [try #require(page.navigationItem.additionalOverflowItems)])
|
|
} else {
|
|
menu = try #require(toolbarButton(namedOneOf: ["More", "更多"], in: navigation)?.menu)
|
|
}
|
|
let actions = try await resolvedMenuActions(menu, in: page.view)
|
|
for names: Set<String> in [["Conversations", "对话", "對話"], ["Archived", "已归档", "已封存"]] {
|
|
let action = try #require(actions.first { names.contains($0.title) })
|
|
#expect(!action.attributes.contains(.disabled))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func expectAssistantSwitcher(
|
|
in navigation: UINavigationController, layout: MobileToolbarLayout, enabled: Bool
|
|
) throws {
|
|
let page = try #require(navigation.topViewController)
|
|
let switcher = toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation)
|
|
if layout.usesIconSwitcher {
|
|
#expect(try #require(switcher).isEnabled == enabled)
|
|
} else {
|
|
#expect(switcher == nil)
|
|
#expect((page.navigationItem.titleMenuProvider != nil) == enabled)
|
|
#expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == page.navigationItem.title })
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
|
|
func assistantTitleAdaptsWithoutLosingItsConversationMenuOrDrafts(language: String) async throws {
|
|
guard UIDevice.current.userInterfaceIdiom == .pad else { return }
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.selectedDestination = .assistant
|
|
await scene.assistant.load()
|
|
let first = ConversationModel()
|
|
first.appendUserTurn("Investigate the production database latency")
|
|
first.customTitle = language == "en" ? "Investigate the production database latency" : "排查生产数据库延迟与连接问题"
|
|
first.isPinned = true
|
|
first.composer.draft = "Keep the first draft"
|
|
let second = ConversationModel()
|
|
second.appendUserTurn("Review the deployment")
|
|
second.composer.draft = "Keep the second draft"
|
|
scene.assistant.library.insertAndSelect(second)
|
|
scene.assistant.library.insertAndSelect(first)
|
|
try await withRoot(app: app, scene: scene, language: language) { controller in
|
|
let tabs = try #require(find(UITabBarController.self, in: controller))
|
|
let navigation = try #require(navigation(titled: first.derivedTitle, in: controller))
|
|
let page = try #require(navigation.topViewController)
|
|
for (sizeClass, sidebar): (UIUserInterfaceSizeClass, Bool) in [
|
|
(.regular, false), (.regular, true), (.compact, false), (.regular, false),
|
|
] {
|
|
resize(controller, to: sizeClass, width: sizeClass == .regular ? 1366 : 430)
|
|
tabs.sidebar.isHidden = !sidebar
|
|
try await settle()
|
|
let topBar = sizeClass == .regular && !sidebar
|
|
capture(controller, name: "assistant-title-\(sizeClass.rawValue)-\(sidebar)-\(language)")
|
|
#expect(navigation.topViewController === page)
|
|
#expect(scene.assistant.conversation === first)
|
|
#expect(first.composer.draft == "Keep the first draft")
|
|
#expect(
|
|
visibleLabels(in: navigation.navigationBar).contains { $0.text == first.derivedTitle } == !topBar)
|
|
let switcher = toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation)
|
|
#expect((switcher != nil) == topBar)
|
|
if let switcher {
|
|
let trailingButtons =
|
|
page.navigationItem.trailingItemGroups.flatMap(\.barButtonItems)
|
|
+ (page.navigationItem.rightBarButtonItems ?? [])
|
|
// System overflow is owned by UINavigationItem rather than a second custom bar button.
|
|
let expectedButtons = if #available(iOS 27.0, *) { 1 } else { 2 }
|
|
#expect(Set(trailingButtons.map(ObjectIdentifier.init)).count == expectedButtons)
|
|
if #available(iOS 27.0, *) { #expect(page.navigationItem.additionalOverflowItems != nil) }
|
|
let menu = try #require(switcher.menu)
|
|
let actions = try await resolvedMenuActions(menu, in: page.view)
|
|
#expect(actions.map(\.title).contains(first.derivedTitle))
|
|
let selectSecond = try #require(actions.first { $0.title == second.derivedTitle })
|
|
UIButton(primaryAction: selectSecond).sendActions(for: .primaryActionTriggered)
|
|
try await settle()
|
|
#expect(scene.assistant.conversation === second)
|
|
#expect(second.composer.draft == "Keep the second draft")
|
|
let updatedSwitcher = try #require(
|
|
toolbarButton(namedOneOf: ["Conversations", "对话", "對話"], in: navigation))
|
|
let updatedMenu = try #require(updatedSwitcher.menu)
|
|
let updatedActions = try await resolvedMenuActions(updatedMenu, in: page.view)
|
|
let selectFirst = try #require(updatedActions.first { $0.title == first.derivedTitle })
|
|
UIButton(primaryAction: selectFirst).sendActions(for: .primaryActionTriggered)
|
|
try await settle()
|
|
#expect(scene.assistant.conversation === first)
|
|
}
|
|
}
|
|
for route in [MobileAssistantLibraryRoute.conversations, .archived] {
|
|
scene.assistantLibraryRoute = route
|
|
try await settle()
|
|
let title = try #require(navigation.topViewController?.navigationItem.title)
|
|
#expect(!title.isEmpty && title != first.derivedTitle)
|
|
#expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == title })
|
|
capture(controller, name: "assistant-library-\(route.rawValue)-\(language)")
|
|
scene.assistantLibraryRoute = nil
|
|
try await settle()
|
|
#expect(navigation.topViewController === page)
|
|
#expect(!visibleLabels(in: navigation.navigationBar).contains { $0.text == first.derivedTitle })
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
|
|
func metricsDetailTitleAdaptsWhileItsPickerAndNavigationSurvive(language: String) async throws {
|
|
guard UIDevice.current.userInterfaceIdiom == .pad else { return }
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.selectedDestination = .metrics
|
|
let server = ServerMetric(
|
|
selection: MetricsSelection(id: UUID(), enabled: false),
|
|
name: language == "en" ? "Production database server in Singapore" : "新加坡生产环境数据库服务器")
|
|
var features = MobileFeatureRegistry()
|
|
for feature in MobileFeatureRegistry.standard().registrations {
|
|
features.register(
|
|
feature.id == .metrics
|
|
? MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in
|
|
AnyView(MetricsDetailFeature(app: app, server: server))
|
|
} : feature)
|
|
}
|
|
try await withRoot(app: app, scene: scene, language: language, features: features) { controller in
|
|
let tabs = try #require(find(UITabBarController.self, in: controller))
|
|
let navigation = try #require(navigation(titled: server.name, in: controller))
|
|
let page = try #require(navigation.topViewController)
|
|
var selectedProcesses = false
|
|
for (sizeClass, width, sidebar): (UIUserInterfaceSizeClass, CGFloat, Bool) in [
|
|
(.regular, 1024, false), (.regular, 600, false), (.regular, 1366, true),
|
|
(.compact, 430, false), (.regular, 1024, false), (.compact, 430, false),
|
|
] {
|
|
resize(controller, to: sizeClass, width: width)
|
|
tabs.sidebar.isHidden = !sidebar
|
|
try await settle()
|
|
capture(controller, name: "metrics-title-\(Int(width))-\(sidebar)-\(language)")
|
|
#expect(navigation.topViewController === page)
|
|
#expect(navigation.viewControllers.count == 2)
|
|
#expect(page.navigationItem.title == server.name)
|
|
if #available(iOS 27.0, *) {
|
|
#expect(page.navigationItem.additionalOverflowItems != nil)
|
|
#expect(page.navigationItem.overflowPresentationSource != nil)
|
|
} else {
|
|
#expect(toolbarButton(namedOneOf: ["Metrics", "监控", "監控"], in: navigation) != nil)
|
|
}
|
|
if sidebar {
|
|
#expect(visibleLabels(in: navigation.navigationBar).contains { $0.text == server.name })
|
|
} else if sizeClass == .regular {
|
|
#expect(!visibleLabels(in: navigation.navigationBar).contains { $0.text == server.name })
|
|
}
|
|
// The bottom bar leaves the principal toolbar slot available for the existing picker.
|
|
if sizeClass == .compact {
|
|
let picker = try #require(find(UISegmentedControl.self, in: controller.view))
|
|
#expect(picker.numberOfSegments == 2)
|
|
#expect(picker.selectedSegmentIndex == (selectedProcesses ? 1 : 0))
|
|
picker.selectedSegmentIndex = 1
|
|
picker.sendActions(for: .valueChanged)
|
|
selectedProcesses = true
|
|
try await settle()
|
|
#expect(find(UICollectionView.self, in: page.view) != nil)
|
|
}
|
|
}
|
|
navigation.popViewController(animated: false)
|
|
try await settle()
|
|
#expect(navigation.viewControllers.count == 1)
|
|
#expect(scene.selectedDestination == .metrics)
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow) func settingsDepthAndUnsavedFormsSurviveWidthChanges() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.openSettings()
|
|
try await withRoot(app: app, scene: scene) { controller in
|
|
let settings = try #require(navigation(titled: "Settings", in: controller))
|
|
try selectRow(4, inSectionWithCount: 6, in: settings.topViewController!)
|
|
try await settle()
|
|
let ai = try #require(navigation(titled: "AI", in: controller))
|
|
try selectRow(0, inSectionWithCount: 4, in: ai.topViewController!)
|
|
try await settle()
|
|
let models = try #require(navigation(titled: "Models", in: controller))
|
|
let detail = models.topViewController
|
|
for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular, .compact] {
|
|
resize(controller, to: sizeClass)
|
|
try await settle()
|
|
#expect(navigation(titled: "Models", in: controller) === models)
|
|
#expect(models.topViewController === detail)
|
|
}
|
|
scene.openNewHost()
|
|
try await settle()
|
|
let hostID = try #require(scene.editingHost?.id)
|
|
let sheet = try #require(presented(in: controller))
|
|
let field = try #require(find(UITextField.self, in: sheet.view))
|
|
field.text = "Preserved draft"
|
|
field.sendActions(for: .editingChanged)
|
|
for sizeClass: UIUserInterfaceSizeClass in [.regular, .compact, .regular] {
|
|
resize(controller, to: sizeClass)
|
|
try await settle()
|
|
#expect(scene.editingHost?.id == hostID)
|
|
#expect(presented(in: controller) === sheet)
|
|
#expect(field.text == "Preserved draft")
|
|
#expect(app.library.hosts.isEmpty)
|
|
}
|
|
sheet.dismiss(animated: false)
|
|
try await settle()
|
|
scene.openSettings()
|
|
try await settle()
|
|
#expect(navigation(titled: "Models", in: controller) === models)
|
|
#expect(models.topViewController === detail)
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow) func vaultSearchAndScrollSurviveResizing() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
for index in 0..<40 {
|
|
app.library.upsert(TermindDomain.Host(name: "Production \(index)", host: "server-\(index).example.invalid"))
|
|
}
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let nativeSizeClass = windowScene.traitCollection.horizontalSizeClass
|
|
let nativeWidth: CGFloat = nativeSizeClass == .regular ? 1024 : 430
|
|
try await withRoot(app: app, scene: scene, initialSizeClass: nativeSizeClass) { controller in
|
|
let navigation = try #require(navigation(titled: "Vault", in: controller))
|
|
let page = try #require(navigation.topViewController)
|
|
let search = try #require(page.navigationItem.searchController)
|
|
search.searchBar.text = "Production"
|
|
search.searchBar.delegate?.searchBar?(search.searchBar, textDidChange: "Production")
|
|
search.searchResultsUpdater?.updateSearchResults(for: search)
|
|
try await settle()
|
|
let scroll = try #require(find(UIScrollView.self, in: page.view))
|
|
scroll.setContentOffset(CGPoint(x: 0, y: 300), animated: false)
|
|
try await settle()
|
|
capture(controller, name: "vault-scroll-before-resize")
|
|
// iPhone stays in its native width class; iPad also crosses regular/compact.
|
|
// Forcing regular width on a compact-only phone changes unsupported search-bar placement.
|
|
for (width, sizeClass): (CGFloat, UIUserInterfaceSizeClass) in [
|
|
(375, .compact), (nativeWidth, nativeSizeClass), (320, .compact),
|
|
] {
|
|
resize(controller, to: sizeClass, width: width)
|
|
try await settle()
|
|
capture(controller, name: "vault-scroll-\(sizeClass.rawValue)")
|
|
#expect(navigation.topViewController === page)
|
|
#expect(page.navigationItem.searchController === search)
|
|
#expect(search.searchBar.text == "Production")
|
|
#expect(find(UIScrollView.self, in: page.view) === scroll)
|
|
#expect(
|
|
scroll.contentOffset.y > 100,
|
|
"offset \(scroll.contentOffset), content \(scroll.contentSize), bounds \(scroll.bounds)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow) func twoWindowsKeepIndependentPagesAndDraftsDuringResizing() async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let first = MobileSceneModel(app: app, sessionID: "test")
|
|
let second = MobileSceneModel(app: app, sessionID: "other")
|
|
first.selectedDestination = .metrics
|
|
second.openNewHost()
|
|
let hostID = second.editingHost?.id
|
|
try await withRoot(app: app, scene: first) { firstRoot in
|
|
let metrics = try #require(navigation(titled: "Metrics", in: firstRoot))
|
|
try await withRoot(app: app, scene: second) { secondRoot in
|
|
let sheet = try #require(presented(in: secondRoot))
|
|
resize(firstRoot, to: .compact)
|
|
try await settle()
|
|
#expect(first.selectedDestination == .metrics)
|
|
#expect(navigation(titled: "Metrics", in: firstRoot) === metrics)
|
|
#expect(second.editingHost?.id == hostID)
|
|
#expect(presented(in: secondRoot) === sheet)
|
|
resize(secondRoot, to: .compact)
|
|
resize(firstRoot, to: .regular)
|
|
try await settle()
|
|
#expect(presented(in: secondRoot) === sheet)
|
|
#expect(first.editingHost == nil)
|
|
#expect(second.selectedDestination == .vault)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow, arguments: [false, true])
|
|
func savingSurvivesResizingThroughCompletionAndErrorFeedback(fails: Bool) async throws {
|
|
let app = try MobileAppModel(inMemory: true)
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
scene.openSettings()
|
|
let draft = SavingDraft(fails: fails)
|
|
var features = MobileFeatureRegistry()
|
|
features.register(
|
|
MobileFeatureRegistration(id: .settings, title: "Settings", symbol: "gearshape") { _, _ in
|
|
AnyView(SavingFeature(draft: draft))
|
|
})
|
|
defer { draft.finish() }
|
|
try await withRoot(app: app, scene: scene, features: features) { controller in
|
|
let sheet = try #require(presented(in: controller))
|
|
let editor = try #require(find(UINavigationController.self, in: sheet))
|
|
let item = try #require(editor.topViewController?.navigationItem)
|
|
let buttons = item.trailingItemGroups.flatMap(\.barButtonItems) + (item.rightBarButtonItems ?? [])
|
|
let save = try #require(buttons.first { ($0.accessibilityLabel ?? $0.title) == "Save" })
|
|
let action = try #require(save.action)
|
|
#expect(UIApplication.shared.sendAction(action, to: save.target, from: save, for: nil))
|
|
try await settle()
|
|
for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular, .compact] {
|
|
resize(controller, to: sizeClass)
|
|
try await settle()
|
|
#expect(presented(in: controller) === sheet)
|
|
#expect(draft.saveCount == 1)
|
|
capture(sheet, name: "saving-\(sizeClass == .regular ? "wide" : "narrow")")
|
|
}
|
|
draft.finish()
|
|
try await settle()
|
|
if fails {
|
|
#expect(draft.isPresented)
|
|
#expect(draft.text == "Unsaved input")
|
|
let alert = try #require(find(UIAlertController.self, in: sheet))
|
|
resize(controller, to: .regular)
|
|
try await settle()
|
|
#expect(presented(in: controller) === sheet)
|
|
#expect(find(UIAlertController.self, in: sheet) === alert)
|
|
#expect(draft.text == "Unsaved input")
|
|
} else {
|
|
#expect(!draft.isPresented)
|
|
for _ in 0..<20 where presented(in: controller) != nil {
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
#expect(presented(in: controller) == nil)
|
|
}
|
|
#expect(draft.saveCount == 1)
|
|
}
|
|
}
|
|
|
|
@Test(.requiresKeyWindow, arguments: ["en", "zh-Hans", "zh-Hant"])
|
|
func memoryWorkspaceListFocusesSelectedRowAndNavigates(language: String) async throws {
|
|
let suite = "termind.recent-workspace-ui.\(UUID())"
|
|
let defaults = try #require(UserDefaults(suiteName: suite))
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let seed = try MobileAppModel(inMemory: true)
|
|
let host = TermindDomain.Host(name: "Production", host: "server.example.invalid")
|
|
seed.library.upsert(host)
|
|
_ = seed.connectionHistory.record(kind: .ssh, for: host.id)
|
|
let descriptors = [
|
|
MobileWorkspaceDescriptor(hostID: host.id, title: "Release Shell", kind: .ssh),
|
|
MobileWorkspaceDescriptor(hostID: host.id, title: "Project Files", kind: .sftp),
|
|
]
|
|
for sizeClass: UIUserInterfaceSizeClass in [.compact, .regular] {
|
|
var dependencies = MobileAppDependencies.testing()
|
|
dependencies.container = seed.container
|
|
let app = try MobileAppModel(dependencies: dependencies)
|
|
defer { app.testWorkspaceSession.closeAll() }
|
|
app.testWorkspaceSession.restoreClosedStates(descriptors.map { .init(descriptor: $0) })
|
|
let scene = MobileSceneModel(app: app, sessionID: "test")
|
|
let historyCount = app.connectionHistory.entries().count
|
|
scene.selectedDestination = .workspaces
|
|
try await withRoot(app: app, scene: scene, language: language, initialSizeClass: sizeClass) { root in
|
|
#expect(app.workspaces.count == 2)
|
|
#expect(scene.workspaceCount == 2)
|
|
#expect(scene.canCloseAllWorkspaces)
|
|
let title = language == "en" ? "Workspaces" : language == "zh-Hans" ? "工作区" : "工作區"
|
|
let navigation = try #require(navigation(titled: title, in: root))
|
|
let page = try #require(navigation.topViewController)
|
|
let list = try #require(find(UICollectionView.self, in: page.view))
|
|
#expect(list.numberOfSections == 2) // Transfers and the unified Workspaces section.
|
|
#expect((0..<list.numberOfSections).contains { list.numberOfItems(inSection: $0) == 2 })
|
|
#expect(app.testWorkspaceSession.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id))
|
|
let width = sizeClass == .compact ? "narrow" : "wide"
|
|
capture(root, name: "recent-workspaces-\(width)-\(language)")
|
|
// SwiftUI Button uses its gesture action, not UICollectionView's selection delegate.
|
|
scene.selectWorkspace(descriptors[1].id)
|
|
try await settle()
|
|
#expect(scene.workspaceID == descriptors[1].id)
|
|
#expect(app.workspaces.map(\.id) == descriptors.map(\.id))
|
|
#expect(app.testWorkspaceSession.recentWorkspaces.isEmpty)
|
|
#expect(app.testWorkspaceSession.orderedWorkspaceItems.map(\.id) == descriptors.map(\.id))
|
|
#expect(list.numberOfSections == 2)
|
|
#expect((0..<list.numberOfSections).contains { list.numberOfItems(inSection: $0) == 2 })
|
|
#expect(navigation.viewControllers.count == 2)
|
|
capture(root, name: "recent-workspace-opened-\(width)-\(language)")
|
|
navigation.popViewController(animated: false)
|
|
try await settle()
|
|
#expect(scene.workspaceID == nil)
|
|
scene.closeAllWorkspaces()
|
|
try await settle()
|
|
#expect(app.testWorkspaceSession.recentWorkspaces.isEmpty)
|
|
#expect(!scene.canCloseAllWorkspaces)
|
|
#expect(app.connectionHistory.entries().count == historyCount)
|
|
#expect(MobileWorkspaceListView(app: app, scene: scene).historyRows.isEmpty == false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func selectRow(_ row: Int, inSectionWithCount count: Int, in controller: UIViewController) throws {
|
|
let list = try #require(find(UICollectionView.self, in: controller.view))
|
|
let section = try #require((0..<list.numberOfSections).first { list.numberOfItems(inSection: $0) == count })
|
|
let path = IndexPath(item: row, section: section)
|
|
list.selectItem(at: path, animated: false, scrollPosition: [])
|
|
list.delegate?.collectionView?(list, didSelectItemAt: path)
|
|
}
|
|
|
|
private func presented(in controller: UIViewController) -> UIViewController? {
|
|
if let presented = controller.presentedViewController { return presented }
|
|
return controller.children.lazy.compactMap { presented(in: $0) }.first
|
|
}
|
|
|
|
private func withRoot(
|
|
app: MobileAppModel, scene: MobileSceneModel, language: String = "en",
|
|
features: MobileFeatureRegistry = .standard(),
|
|
typeSize: DynamicTypeSize = .large,
|
|
initialSizeClass: UIUserInterfaceSizeClass = .regular,
|
|
regions: MobileRegionTestState? = nil,
|
|
check: (UIViewController) async throws -> Void
|
|
) async throws {
|
|
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let previous = windowScene.keyWindow
|
|
let window = UIWindow(windowScene: windowScene)
|
|
let container = UIViewController()
|
|
let controller = UIHostingController(
|
|
rootView: MobileRegionTestHost(state: regions) {
|
|
MobileRootView(app: app, scene: scene, features: features)
|
|
}
|
|
.environment(\.locale, Locale(identifier: language))
|
|
.environment(\.dynamicTypeSize, typeSize))
|
|
window.rootViewController = container
|
|
container.addChild(controller)
|
|
container.view.addSubview(controller.view)
|
|
controller.didMove(toParent: container)
|
|
window.windowLevel = .alert + 3
|
|
window.makeKeyAndVisible()
|
|
resize(controller, to: initialSizeClass)
|
|
defer {
|
|
window.isHidden = true
|
|
window.rootViewController = nil
|
|
previous?.makeKey()
|
|
}
|
|
try await settle()
|
|
try await check(controller)
|
|
}
|
|
|
|
private func resize(_ controller: UIViewController, to sizeClass: UIUserInterfaceSizeClass, width: CGFloat? = nil) {
|
|
let frame = CGRect(x: 0, y: 0, width: width ?? (sizeClass == .regular ? 1024 : 430), height: 800)
|
|
controller.view.window?.frame = frame
|
|
controller.parent?.view.frame = frame
|
|
controller.traitOverrides.horizontalSizeClass = sizeClass
|
|
controller.view.frame = frame
|
|
controller.view.setNeedsLayout()
|
|
controller.view.layoutIfNeeded()
|
|
}
|
|
|
|
private func find<T: UIViewController>(_ type: T.Type, in controller: UIViewController) -> T? {
|
|
if let match = controller as? T { return match }
|
|
if let presented = controller.presentedViewController, let match = find(type, in: presented) { return match }
|
|
return controller.children.lazy.compactMap { find(type, in: $0) }.first
|
|
}
|
|
|
|
private func find<T: UIView>(_ type: T.Type, in view: UIView) -> T? {
|
|
if let match = view as? T { return match }
|
|
return view.subviews.lazy.compactMap { find(type, in: $0) }.first
|
|
}
|
|
|
|
private func navigation(titled title: String, in controller: UIViewController) -> UINavigationController? {
|
|
if let navigation = controller as? UINavigationController,
|
|
navigation.topViewController?.navigationItem.title == title
|
|
{
|
|
return navigation
|
|
}
|
|
return controller.children.lazy.compactMap { navigation(titled: title, in: $0) }.first
|
|
}
|
|
|
|
private func visibleLabels(in view: UIView) -> [UILabel] {
|
|
guard !view.isHidden, view.alpha > 0, view.bounds.width > 0, view.bounds.height > 0 else { return [] }
|
|
return (view as? UILabel).map { [$0] } ?? view.subviews.flatMap { visibleLabels(in: $0) }
|
|
}
|
|
|
|
private func toolbarButton(namedOneOf names: Set<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 ?? button.title ?? button.customView?.accessibilityLabel).map(names.contains)
|
|
== true
|
|
}
|
|
}
|
|
|
|
private func resolvedMenuActions(_ menu: UIMenu, in view: UIView) async throws -> [UIAction] {
|
|
// SwiftUI supplies deferred menu elements. Present the native menu to ask UIKit to resolve them.
|
|
let button = UIButton(frame: CGRect(x: 20, y: 100, width: 44, height: 44))
|
|
button.menu = menu
|
|
button.showsMenuAsPrimaryAction = true
|
|
view.addSubview(button)
|
|
defer { button.removeFromSuperview() }
|
|
button.performPrimaryAction()
|
|
try await settle()
|
|
let interaction = try #require(button.contextMenuInteraction)
|
|
var actions: [UIAction] = []
|
|
interaction.updateVisibleMenu { resolved in
|
|
actions = menuActions(resolved)
|
|
return resolved
|
|
}
|
|
interaction.dismissMenu()
|
|
try await settle()
|
|
return actions
|
|
}
|
|
|
|
private func settle() async throws { try await Task.sleep(for: .milliseconds(500)) }
|
|
|
|
private func capture(_ controller: UIViewController, name: String) {
|
|
let data = UIGraphicsImageRenderer(bounds: controller.view.bounds).pngData { _ in
|
|
controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
|
|
}
|
|
Attachment.record(data, named: "\(name).png")
|
|
}
|
|
}
|
|
|
|
private struct MetricsDetailFeature: View {
|
|
let app: MobileAppModel
|
|
let server: ServerMetric
|
|
@State private var showsDetail = true
|
|
|
|
var body: some View {
|
|
Text("Metrics")
|
|
.navigationTitle("Metrics")
|
|
.navigationDestination(isPresented: $showsDetail) {
|
|
MobileMetricsDetailView(app: app, server: server)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Observable @MainActor private final class SavingDraft {
|
|
enum Failure: Error { case expected }
|
|
var isPresented = true
|
|
var text = "Unsaved input"
|
|
var saveCount = 0
|
|
var continuation: CheckedContinuation<Void, Never>?
|
|
let fails: Bool
|
|
|
|
init(fails: Bool) { self.fails = fails }
|
|
|
|
func save() async throws {
|
|
saveCount += 1
|
|
await withCheckedContinuation { continuation = $0 }
|
|
try Task.checkCancellation()
|
|
if fails { throw Failure.expected }
|
|
}
|
|
|
|
func finish() {
|
|
continuation?.resume()
|
|
continuation = nil
|
|
}
|
|
}
|
|
|
|
private struct SavingFeature: View {
|
|
@Bindable var draft: SavingDraft
|
|
var body: some View {
|
|
Text("Settings")
|
|
.navigationTitle("Settings")
|
|
.sheet(isPresented: $draft.isPresented) {
|
|
MobileEditorSheet(title: "Snippet", isDirty: true, save: draft.save) {
|
|
TextField("Title", text: $draft.text)
|
|
}
|
|
}
|
|
}
|
|
}
|