Files
web-terminal/ios/App/WebTermTests/TerminalContextMenuTests.swift
Yaojia Wang 823432b1c8 feat(ipad): W1-W3 — adaptive split-view layout + finding fixes
T-iPad-2: AdaptiveRootView/LayoutPolicy (sole size-class decision), SplitRootView
(NavigationSplitView sidebar+detail), StackRootView (iPhone path verbatim, zero
regression); privacy shade hoisted to shared ZStack top for both branches
T-iPad-3: KeyBarVisibility predicate (hide when hardware keyboard present),
pointer context menu (copy/new-in-cwd/kill, all via existing channels)
T-iPad-4: Projects multi-column grid on iPad (idiom-gated), adaptive sheet detents
T-iPad-5 findings (4/4 fixed): kill onKillSession thread-through +
AppCoordinator.killCurrentSession; split route-gated to .sessions (iPad first-run
pairing); continue-last banner in split sidebar; iPad XCUITest deferred (covered
by SidebarSelectionTests)
Verified: iPhone 16 277 + iPad Pro 11 278 tests green; packages 261 + integration 10;
zero changes under ios/Packages, src/, public/
2026-07-05 19:58:30 +02:00

140 lines
5.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import APIClient
import Foundation
import TestSupport
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// T-iPad-3 · /****
///
/// - `.newInCwd` T-iOS-29 `onNewInCwd` cwd fresh spawn
/// - `.kill` `onKill`wiring `APIClient.killSession` Origin
/// G RO/G
/// - `.copySelection` SwiftTerm copy
@MainActor
@Suite("TerminalContextMenu (T-iPad-3)")
struct TerminalContextMenuTests {
private nonisolated static let base = "http://192.168.1.5:3000"
/// perform
@MainActor
private final class ChannelRecorder {
var newInCwd = 0
var kill = 0
var copy = 0
}
/// onKill fire-and-forget Task await
@MainActor
private final class TaskBox {
var task: Task<Void, Never>?
}
private func endpoint() throws -> HostEndpoint {
let url = try #require(URL(string: Self.base))
return try #require(HostEndpoint(baseURL: url))
}
private func deleteURL(id: UUID) throws -> URL {
try #require(URL(string: "\(Self.base)/live-sessions/\(id.uuidString.lowercased())"))
}
// MARK: - 1. iPad iPhone SwiftTerm
@Test("isPointerMenuEnablediPad 开、iPhone 关idiom 与 size class 正交)")
func pointerMenuEnabledOnlyOnPad() {
#expect(TerminalContextMenu.isPointerMenuEnabled(idiom: .pad))
#expect(!TerminalContextMenu.isPointerMenuEnabled(idiom: .phone))
}
// MARK: - 2.
@Test("三项全可用 → 复制选区、开新会话、结束会话固定顺序kill 破坏性)")
func itemsAllAvailableInOrder() {
let items = TerminalContextMenu.items(canCopySelection: true, canNewInCwd: true, canKill: true)
#expect(items.map(\.action) == [.copySelection, .newInCwd, .kill])
#expect(items.map(\.title) == [
TerminalContextMenu.Copy.copySelection,
TerminalContextMenu.Copy.newInCwd,
TerminalContextMenu.Copy.kill,
])
#expect(items.first { $0.action == .kill }?.isDestructive == true)
}
@Test("无选区 → 隐藏复制选区;无 kill 通道 → 隐藏结束会话")
func itemsFilteredByAvailability() {
let noCopy = TerminalContextMenu.items(canCopySelection: false, canNewInCwd: true, canKill: true)
#expect(!noCopy.contains { $0.action == .copySelection })
let noKill = TerminalContextMenu.items(canCopySelection: true, canNewInCwd: true, canKill: false)
#expect(!noKill.contains { $0.action == .kill })
let empty = TerminalContextMenu.items(canCopySelection: false, canNewInCwd: false, canKill: false)
#expect(empty.isEmpty)
}
// MARK: - 3. Model.items + perform
@Test("model.itemsonKill=nil → 无结束会话hasSelection=false → 无复制选区")
func modelItemsReflectInjectedAvailability() {
let model = TerminalContextMenuModel(
onNewInCwd: {},
onKill: nil,
onCopySelection: {},
hasSelection: { false }
)
#expect(model.items.map(\.action) == [.newInCwd])
}
@Test("perform 把每个动作路由到对应既有闭包(不误触其它通道)")
func performRoutesToExistingChannels() {
let recorder = ChannelRecorder()
let model = TerminalContextMenuModel(
onNewInCwd: { recorder.newInCwd += 1 },
onKill: { recorder.kill += 1 },
onCopySelection: { recorder.copy += 1 },
hasSelection: { true }
)
model.perform(.copySelection)
#expect((recorder.copy, recorder.newInCwd, recorder.kill) == (1, 0, 0))
model.perform(.newInCwd)
#expect((recorder.copy, recorder.newInCwd, recorder.kill) == (1, 1, 0))
model.perform(.kill)
#expect((recorder.copy, recorder.newInCwd, recorder.kill) == (1, 1, 1))
}
// MARK: - 4. kill APIClient.killSession Origin
@Test("kill 动作 → 唯一一条 DELETE /live-sessions/:id带精确 Origin复用 APIClient无绕过")
func killActionReusesAPIClientWithOrigin() async throws {
// Arrange APIClient over FakeHTTPTransport wiring kill
let endpoint = try endpoint()
let http = FakeHTTPTransport()
let sessionId = UUID()
await http.queueSuccess(method: "DELETE", url: try deleteURL(id: sessionId), status: 204)
let api = APIClient(endpoint: endpoint, http: http)
let box = TaskBox()
let model = TerminalContextMenuModel(
onNewInCwd: nil,
onKill: { box.task = Task { try? await api.killSession(id: sessionId) } },
onCopySelection: {},
hasSelection: { false }
)
// Act kill
model.perform(.kill)
await box.task?.value
// AssertDELETE kill OriginG
let requests = await http.recordedRequests
#expect(requests.count == 1)
let deleteRequest = requests.first { $0.httpMethod == "DELETE" }
#expect(deleteRequest?.url == (try deleteURL(id: sessionId)))
#expect(deleteRequest?.value(forHTTPHeaderField: "Origin") == endpoint.originHeader)
}
}