Files
Termind/TFITests/MobileTerminalKeyboardTests.swift
T

277 lines
11 KiB
Swift

import Foundation
import GhosttyTerminal
import SwiftUI
import Testing
import UIKit
@testable import TermindIOS
@Suite @MainActor
struct MobileTerminalKeyboardTests {
@Test func nativeAccessoryUsesUpstreamGroupsWithPasteLast() throws {
let terminal = MobileTerminalView(frame: .zero)
let items = terminal.inputAccessoryItems
#expect(
items == [
.esc, .tab, .ctrl, .alt, .command, .divider,
.arrowLeft, .arrowUp, .arrowDown, .arrowRight, .divider,
.symbol("|"), .symbol("/"), .symbol("~"), .symbol("-"), .symbol("_"),
.symbol("`"), .symbol("'"), .symbol("\""), .paste,
])
let bar = try #require(terminal.inputAccessoryView)
let buttons = accessoryButtons(in: bar)
#expect(buttons.map(\.accessibilityLabel) == items.compactMap(\.title))
}
@Test func nativeAccessoryKeepsItsGeometryAndAllKeysReachable() throws {
for width in [CGFloat(320), 390, 430, 844, 1024] {
let terminal = MobileTerminalView(frame: .zero)
let bar = try #require(terminal.inputAccessoryView)
let expectedHeight: CGFloat = if #available(iOS 26, *) { 60 } else { 52 }
#expect(bar.intrinsicContentSize.height == expectedHeight)
bar.frame = CGRect(x: 0, y: 0, width: width, height: expectedHeight)
bar.layoutIfNeeded()
let scroll = try #require(descendants(of: bar).compactMap { $0 as? UIScrollView }.first)
#expect(scroll.contentSize.width > 0)
let buttons = accessoryButtons(in: bar)
for button in buttons {
#expect(button.bounds.size == CGSize(width: 36, height: 36))
}
let paste = try #require(buttons.last)
scroll.setContentOffset(
CGPoint(x: max(0, scroll.contentSize.width - scroll.bounds.width), y: 0), animated: false)
#expect(scroll.bounds.contains(paste.convert(paste.bounds, to: scroll)))
}
}
}
/// Hosts the library's actual UIView for the existing opt-in rendering matrix.
struct MobileTerminalAccessoryPreview: UIViewRepresentable {
var locksControl = false
var scrollsToEnd = false
func makeCoordinator() -> MobileTerminalView {
let terminal = MobileTerminalView(frame: .zero)
if locksControl {
terminal.toggleStickyModifier(.ctrl)
terminal.toggleStickyModifier(.ctrl)
}
return terminal
}
func makeUIView(context: Context) -> UIView {
context.coordinator.inputAccessoryView!
}
func updateUIView(_ uiView: UIView, context: Context) {
guard scrollsToEnd else { return }
DispatchQueue.main.async {
uiView.layoutIfNeeded()
if let scroll = descendants(of: uiView).compactMap({ $0 as? UIScrollView }).first {
scroll.setContentOffset(
CGPoint(x: max(0, scroll.contentSize.width - scroll.bounds.width), y: 0), animated: false)
}
}
}
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? {
CGSize(width: proposal.width ?? 390, height: uiView.intrinsicContentSize.height)
}
}
@MainActor
private func descendants(of view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + descendants(of: $0) }
}
@MainActor
private func accessoryButtons(in view: UIView) -> [UIButton] {
descendants(of: view).compactMap { $0 as? UIButton }
}
@Suite(.serialized, .requiresKeyWindow)
@MainActor
struct MobileTerminalKeyboardIntegrationTests {
@Test func initialFocusWaitsForReadinessAndReopensOnlyAfterRemount() async throws {
let app = try MobileAppModel(inMemory: true)
let workspace = MobileWorkspace(
descriptor: .init(hostID: UUID(), title: "Initial Focus", kind: .ssh), hosts: app.library)
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let previous = scene.keyWindow
let window = UIWindow(windowScene: scene)
defer {
workspace.terminal.attachedPlatformView?.resignFirstResponder()
window.isHidden = true
window.rootViewController = nil
previous?.makeKey()
workspace.disconnect()
}
func content(isReady: Bool) -> some View {
TerminalSurfaceView(context: workspace.terminal)
.modifier(MobileTerminalInitialFocus(terminal: workspace.terminal, isReady: isReady))
}
let host = UIHostingController(rootView: content(isReady: false))
window.rootViewController = host
window.makeKeyAndVisible()
try await Task.sleep(for: .milliseconds(600))
let terminal = try #require(workspace.terminal.attachedPlatformView)
let waitsForReadiness = !terminal.isFirstResponder
#expect(waitsForReadiness)
host.rootView = content(isReady: true)
try await Task.sleep(for: .milliseconds(600))
let initiallyFocused = terminal.isFirstResponder
#expect(initiallyFocused)
terminal.resignFirstResponder()
try await Task.sleep(for: .milliseconds(600))
host.rootView = content(isReady: true)
try await Task.sleep(for: .milliseconds(600))
let remainsHidden = !terminal.isFirstResponder
#expect(remainsHidden)
window.rootViewController = nil
window.rootViewController = UIHostingController(rootView: content(isReady: true))
try await Task.sleep(for: .milliseconds(600))
let focusedAfterRemount = terminal.isFirstResponder
#expect(focusedAfterRemount)
}
@Test func nativeButtonsSendKeysAndReservePasteFramingForClipboard() async throws {
let fixture = try KeyboardFixture()
defer { fixture.close() }
try await fixture.ready()
fixture.session.receive("\u{1B}[?2004h")
fixture.session.waitForPendingOutput()
for symbol in ["|", "/", "~", "-", "_", "`", "'", "\""] {
fixture.output.clear()
try fixture.button(title: symbol).sendActions(for: .touchUpInside)
try await fixture.expectOutput(Data(symbol.utf8))
}
let oldClipboard = UIPasteboard.general.items
defer { UIPasteboard.general.items = oldClipboard }
UIPasteboard.general.string = "native paste"
fixture.output.clear()
try fixture.button(item: .paste).sendActions(for: .touchUpInside)
try await fixture.expectOutput(Data("\u{1B}[200~native paste\u{1B}[201~".utf8))
}
@Test func stickyModifiersAreConsumedByNativeKeysAndDisabledDuringComposition() async throws {
let fixture = try KeyboardFixture()
defer { fixture.close() }
try await fixture.ready()
let control = try fixture.button(item: .ctrl)
control.sendActions(for: .touchUpInside)
fixture.terminal.sendKey(.c)
try await fixture.expectOutput(Data([3]))
#expect(fixture.terminal.stickyActivation(for: .ctrl) == .inactive)
control.sendActions(for: .touchUpInside)
control.sendActions(for: .touchUpInside)
fixture.output.clear()
fixture.terminal.sendKey(.c)
try await fixture.expectOutput(Data([3]))
#expect(fixture.terminal.stickyActivation(for: .ctrl) == .locked)
fixture.terminal.resetStickyModifiers()
fixture.terminal.setMarkedText("ni", selectedRange: NSRange(location: 2, length: 0))
#expect(!control.isEnabled)
#expect(try !fixture.button(item: .alt).isEnabled)
#expect(try !fixture.button(item: .command).isEnabled)
fixture.terminal.unmarkText()
#expect(control.isEnabled)
}
@Test func workspaceEnablesTheNativeAccessoryAndPreservesItsSurfaceAcrossKeyboardToggles() async throws {
let fixture = try KeyboardFixture()
defer { fixture.close() }
try await fixture.ready()
let surface = try #require(fixture.terminal.surface)
fixture.terminal.toggleSoftwareKeyboard()
try await Task.sleep(for: .milliseconds(600))
let hidden = !fixture.terminal.isFirstResponder
#expect(hidden)
fixture.terminal.toggleSoftwareKeyboard()
try await Task.sleep(for: .milliseconds(600))
let focused = fixture.terminal.isFirstResponder
#expect(focused)
#expect(fixture.terminal.surface === surface)
let app = try MobileAppModel(inMemory: true)
let workspace = MobileWorkspace(
descriptor: .init(hostID: UUID(), title: "Keyboard", kind: .ssh), hosts: app.library)
defer { workspace.disconnect() }
#expect(workspace.terminal.inputAccessoryItems == nil)
}
}
@MainActor
private final class KeyboardFixture {
let terminal = MobileTerminalSurface().view
let output = KeyboardOutput()
let session: InMemoryTerminalSession
let controller = TerminalController(configSource: .none)
let window: UIWindow
let previous: UIWindow?
init() throws {
let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
previous = scene.keyWindow
window = UIWindow(windowScene: scene)
let output = output
session = InMemoryTerminalSession(write: { output.append($0) }, resize: { _ in })
terminal.configuration = TerminalSurfaceOptions(backend: .inMemory(session))
terminal.controller = controller
let host = UIViewController()
host.view = terminal
window.rootViewController = host
window.makeKeyAndVisible()
}
func ready() async throws {
// Let the previous fixture's keyboard dismissal finish before opening ours.
try await Task.sleep(for: .milliseconds(600))
terminal.becomeFirstResponder()
try await Task.sleep(for: .milliseconds(600))
_ = try #require(terminal.surface)
output.clear()
}
func button(item: TerminalInputAccessoryItem) throws -> UIButton {
let bar = try #require(terminal.inputAccessoryView)
return try #require(
accessoryButtons(in: bar).first {
$0.accessibilityLabel == item.title
})
}
func button(title: String) throws -> UIButton {
let bar = try #require(terminal.inputAccessoryView)
return try #require(accessoryButtons(in: bar).first { $0.configuration?.title == title })
}
func expectOutput(_ expected: Data) async throws {
for _ in 0..<100 {
if output.data == expected { break }
try await Task.sleep(for: .milliseconds(20))
}
#expect(Array(output.data) == Array(expected))
}
func close() {
terminal.resignFirstResponder()
window.isHidden = true
window.rootViewController = nil
terminal.controller = nil
previous?.makeKey()
}
}
private nonisolated final class KeyboardOutput: @unchecked Sendable {
private let lock = NSLock()
private var storage = Data()
var data: Data { lock.withLock { storage } }
func append(_ data: Data) { lock.withLock { storage.append(data) } }
func clear() { lock.withLock { storage.removeAll() } }
}