Files
Termind/TFMTests/ChooserInteractionTests.swift
T

376 lines
15 KiB
Swift

import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite(.requiresVisibleWindow, .serialized) @MainActor
struct ChooserInteractionTests {
@Test(arguments: ["light", "dark", "empty", "reduced-transparency"])
func quickOpenIntegratedGlassRendersAcrossAppearances(_ variant: String) async throws {
let fixture = ChooserFixture()
defer { fixture.close() }
fixture.model.openPage(.hosts)
fixture.mount(
QuickOpenHarness(
workspace: nil,
reduceTransparency: variant == "reduced-transparency"
)
)
try await fixture.settle()
fixture.window.appearance = NSAppearance(
named: variant == "dark" ? .darkAqua : .aqua
)
fixture.window.makeKey()
fixture.model.isQuickOpenPresented = true
let field = try await fixture.quickOpenEditableField()
if variant == "empty" {
field.stringValue = "no matching command exists"
field.delegate?.controlTextDidChange?(
Notification(name: NSControl.textDidChangeNotification, object: field)
)
try await fixture.settle()
}
let image = try fixture.capture()
Attachment.record(image, named: "quick-open-integrated-glass-\(variant).png")
#expect(image.count > 20_000)
}
@Test(arguments: ["toolbar", "ssh", "serial"])
func quickOpenTakesFocusAndHandlesTypingNavigationAndReopening(_ source: String) async throws {
let fixture = ChooserFixture()
defer { fixture.close() }
var workspace: Workspace?
if source == "ssh" {
let session = SSHSessionModel(host: fixture.hosts[0])
session.status = .connected
workspace = fixture.model.open(Workspace(pane: Pane(.sshSession(session))))
} else if source == "serial" {
let session = SerialSessionModel(
configuration: SerialConnectionConfiguration(devicePath: "/dev/termind-test"),
service: fixture.app.serialPorts)
session.status = .connected
workspace = fixture.model.open(Workspace(pane: Pane(.serialSession(session))))
} else {
fixture.model.openPage(.hosts)
}
fixture.mount(QuickOpenHarness(workspace: workspace))
try await fixture.settle()
if source == "toolbar" {
fixture.model.focusToolbarFind()
try await fixture.settle()
#expect(fixture.model.isToolbarFindFocused)
}
fixture.window.makeKey()
fixture.model.isQuickOpenPresented = true
let field = try await fixture.quickOpenField()
#expect(!fixture.model.isToolbarFindFocused)
try fixture.type("New ", into: field)
try await fixture.settle()
#expect(field.stringValue == "New ")
#expect(fixture.window.firstResponder === field.currentEditor())
// New Window → New Chat → New Window → New Chat, then execute.
try fixture.key(String(UnicodeScalar(NSDownArrowFunctionKey)!), code: 125)
try fixture.key(String(UnicodeScalar(NSUpArrowFunctionKey)!), code: 126)
try fixture.key(String(UnicodeScalar(NSDownArrowFunctionKey)!), code: 125)
try fixture.key("\r", code: 36)
try await fixture.settle()
#expect(!fixture.model.isQuickOpenPresented)
#expect(fixture.model.selectedDestination == .assistant)
try await fixture.waitUntilQuickOpenCloses()
fixture.window.makeKey()
fixture.model.isQuickOpenPresented = true
let reopened = try await fixture.quickOpenField()
#expect(reopened.stringValue.isEmpty)
try fixture.key("\u{1B}", code: 53)
try await fixture.settle()
#expect(!fixture.model.isQuickOpenPresented)
}
@Test
func closingBeforeFocusDeliveryAndOpeningInAnotherWindowDoNotStealTheCaret() async throws {
let first = ChooserFixture()
let second = ChooserFixture()
defer {
second.close()
first.close()
}
first.model.openPage(.hosts)
first.mount(QuickOpenHarness(workspace: nil))
try await first.settle()
first.model.isQuickOpenPresented = true
first.window.contentView?.layoutSubtreeIfNeeded()
first.model.isQuickOpenPresented = false
try await first.settle()
first.model.focusToolbarFind()
try await first.settle()
let original = try #require(first.window.firstResponder)
try await first.settle()
#expect(first.window.firstResponder === original)
second.model.openPage(.hosts)
second.mount(QuickOpenHarness(workspace: nil))
try await second.settle()
second.model.focusToolbarFind()
try await second.settle()
let responder = try #require(second.window.firstResponder)
first.model.isQuickOpenPresented = true
try await second.settle()
#expect(second.window.isKeyWindow)
#expect(second.window.firstResponder === responder)
#expect(!second.model.isQuickOpenPresented)
}
@Test
func connectUsesImmediateRowsWhileSerialAddsAnExplicitTopAction() async throws {
let connect = ChooserFixture()
defer { connect.close() }
connect.mount(HostChooserView(purpose: .connect))
try await connect.settle()
let connectActions = connect.elements.filter {
$0.accessibilityIdentifier?() == "host-chooser.confirm"
}
#expect(connectActions.isEmpty)
let picker = try #require(connect.views.compactMap { $0 as? NSSegmentedControl }.first)
#expect(picker.segmentCount == 3)
_ = try await connect.quickOpenField()
try connect.key(String(UnicodeScalar(NSDownArrowFunctionKey)!), code: 125)
try connect.key("\r", code: 36)
try await connect.settle()
#expect(connect.model.workspaces.count == 1)
let serial = ChooserFixture()
defer { serial.close() }
serial.mount(HostChooserView(purpose: .connect))
try await serial.settle()
let serialPicker = try #require(serial.views.compactMap { $0 as? NSSegmentedControl }.first)
serialPicker.selectedSegment = 2
#expect(serialPicker.sendAction(serialPicker.action, to: serialPicker.target))
try await serial.settle()
#expect(serial.model.workspaces.isEmpty)
}
@Test(arguments: [HostOpenTarget.sshSession, .sftpChannel], [1, 3])
func batchConnectAndReturnCreateExactlyOneWorkspace(target: HostOpenTarget, count: Int) async throws {
let fixture = ChooserFixture()
defer { fixture.close() }
fixture.mount(HostChooserView(purpose: .batchStartup))
try await fixture.settle()
#expect(fixture.model.workspaces.isEmpty)
let protocolPicker = try #require(fixture.views.compactMap { $0 as? NSSegmentedControl }.first)
#expect(protocolPicker.segmentCount == 2)
if target == .sftpChannel {
protocolPicker.selectedSegment = 1
#expect(protocolPicker.sendAction(protocolPicker.action, to: protocolPicker.target))
}
_ = try await fixture.quickOpenField()
for _ in 0..<count {
try fixture.key(String(UnicodeScalar(NSDownArrowFunctionKey)!), code: 125)
try fixture.key(" ", code: 49)
}
#expect(fixture.model.workspaces.isEmpty)
try fixture.key("\r", code: 36)
try await fixture.settle()
#expect(fixture.model.workspaces.count == 1)
let workspace = try #require(fixture.model.selectedWorkspace)
#expect(workspace.panes.map(\.hostID) == fixture.hosts.prefix(count).map(\.id))
#expect(workspace.focusedPaneID == workspace.panes.first?.id)
#expect(workspace.panes.allSatisfy { $0.kind == (target == .sshSession ? .sshSession : .sftpChannel) })
}
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
func batchHasOneConnectActionAndCancelCreatesNoSessions(_ language: String) async throws {
let fixture = ChooserFixture()
defer { fixture.close() }
fixture.mount(HostChooserView(purpose: .batchStartup).environment(\.locale, Locale(identifier: language)))
try await fixture.settle()
_ = try await fixture.quickOpenField()
try fixture.key(String(UnicodeScalar(NSDownArrowFunctionKey)!), code: 125)
try fixture.key(" ", code: 49)
try await fixture.settle()
let view = try #require(fixture.window.contentView)
view.layoutSubtreeIfNeeded()
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
let image = try #require(bitmap.representation(using: .png, properties: [:]))
Attachment.record(image, named: "batch-startup-\(language).png")
try image.write(
to: FileManager.default.temporaryDirectory.appending(path: "termind-batch-startup-\(language).png"))
try fixture.key("\u{1B}", code: 53)
try await fixture.settle()
#expect(fixture.model.workspaces.isEmpty)
}
}
private struct QuickOpenHarness: View {
let workspace: Workspace?
var reduceTransparency = false
@Environment(WindowModel.self) private var model
var body: some View {
Group {
if let workspace {
WorkspaceContentView(workspace: workspace, isActive: model.selectedWorkspaceID == workspace.id)
} else {
VStack {
WindowToolbarFindField()
Spacer()
}
}
}
.frame(width: 800, height: 600)
.overlay {
if model.isQuickOpenPresented {
QuickOpenOverlay { model.isQuickOpenPresented = false }
}
}
.environment(\._accessibilityReduceTransparency, reduceTransparency)
}
}
@MainActor
private final class ChooserFixture {
let hosts: [Host]
let app: AppModel
let model = WindowModel()
let window = NSWindow(
contentRect: CGRect(x: 0, y: 0, width: 800, height: 600),
styleMask: [.titled], backing: .buffered, defer: false)
init() {
hosts = ["Alpha", "Bravo", "Charlie"].enumerated().map { index, name in
Host(name: name, host: "\(name.lowercased()).invalid", sortIndex: Double(index))
}
app = AppModel(hosts: HostLibraryModel(hosts: hosts))
window.isReleasedWhenClosed = false
window.animationBehavior = .none
}
func mount(_ view: some View) {
let controller = NSHostingController(rootView: view.environment(app).environment(model))
controller.sizingOptions = []
window.contentViewController = controller
controller.view.frame = window.contentLayoutRect
controller.view.layoutSubtreeIfNeeded()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
var views: [NSView] {
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
return window.contentView.map(descendants) ?? []
}
var elements: [AnyObject] {
var seen = Set<ObjectIdentifier>()
func descendants(_ element: AnyObject) -> [AnyObject] {
guard seen.insert(ObjectIdentifier(element)).inserted else { return [] }
return [element]
+ (element.accessibilityChildren?() ?? []).map { $0 as AnyObject }
.flatMap(descendants)
}
return views.flatMap { descendants($0) }
}
func element(_ identifier: String) throws -> AnyObject {
if !elements.contains(where: { $0.accessibilityIdentifier?() == identifier }) {
print(
"CHOOSER VIEWS",
views.map {
"\(Swift.type(of: $0)): \(($0 as? NSButton)?.title ?? "") \($0.accessibilityIdentifier())"
})
print(
"CHOOSER AX",
elements.map {
"\(Swift.type(of: $0)): \($0.accessibilityLabel?() ?? "") \($0.accessibilityIdentifier?() ?? "")"
})
}
return try #require(elements.first { $0.accessibilityIdentifier?() == identifier }, "Missing \(identifier)")
}
func waitUntilQuickOpenCloses() async throws {
for _ in 0..<100 {
if !views.contains(where: { ($0 as? NSTextField)?.isEditable == true && !($0 is NSSearchField) }) {
return
}
try await Task.sleep(for: .milliseconds(10))
}
throw ChooserTestError.searchDidNotClose
}
func quickOpenField() async throws -> NSTextField {
for _ in 0..<100 {
if let field = views.compactMap({ $0 as? NSTextField }).first(where: {
$0.isEditable && !($0 is NSSearchField) && $0.currentEditor() === window.firstResponder
}) {
return field
}
try await Task.sleep(for: .milliseconds(10))
}
throw ChooserTestError.searchDidNotTakeFocus
}
func quickOpenEditableField() async throws -> NSTextField {
for _ in 0..<100 {
if let field = views.compactMap({ $0 as? NSTextField }).first(where: {
$0.isEditable && !($0 is NSSearchField)
}) {
return field
}
try await Task.sleep(for: .milliseconds(10))
}
throw ChooserTestError.searchDidNotMount
}
func type(_ text: String, into field: NSTextField) throws {
let editor = try #require(field.currentEditor() as? NSTextView)
editor.insertText(text, replacementRange: NSRange(location: 0, length: editor.string.utf16.count))
}
func capture() throws -> Data {
let view = try #require(window.contentView)
view.layoutSubtreeIfNeeded()
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
view.cacheDisplay(in: view.bounds, to: bitmap)
return try #require(bitmap.representation(using: .png, properties: [:]))
}
func key(_ characters: String, code: UInt16) throws {
let event = try #require(
NSEvent.keyEvent(
with: .keyDown, location: .zero, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber, context: nil, characters: characters,
charactersIgnoringModifiers: characters, isARepeat: false, keyCode: code))
NSApp.sendEvent(event)
}
func settle() async throws { try await Task.sleep(for: .milliseconds(100)) }
func close() {
window.orderOut(nil)
window.contentViewController = nil
window.close()
for workspace in model.workspaces {
for pane in workspace.panes { pane.disconnectConnection() }
}
app.shutdownForTermination()
}
}
private enum ChooserTestError: Error {
case searchDidNotTakeFocus
case searchDidNotMount
case searchDidNotClose
}