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? } 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) } }