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/
140 lines
5.7 KiB
Swift
140 lines
5.7 KiB
Swift
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("isPointerMenuEnabled:iPad 开、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.items:onKill=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
|
||
|
||
// Assert:恰好一条请求,DELETE 到 kill 路由,带精确 Origin(G 铁律)。
|
||
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)
|
||
}
|
||
}
|