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/
This commit is contained in:
33
ios/App/WebTermTests/KeyBarVisibilityTests.swift
Normal file
33
ios/App/WebTermTests/KeyBarVisibilityTests.swift
Normal file
@@ -0,0 +1,33 @@
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iPad-3 · KeyBar 可见性纯谓词。硬件键盘在场时软键盘 KeyBar
|
||||
/// (`inputAccessoryView`)冗余 → 默认隐;无硬件键盘 → 显(iPhone 行为不变);
|
||||
/// 用户显式切换(`userOverride`)始终压过自动默认。硬件态经参数注入(模拟器
|
||||
/// 硬件键盘状态未必可脚本化 —— 谓词单测是真值源)。
|
||||
@Suite("KeyBarVisibility (T-iPad-3)")
|
||||
struct KeyBarVisibilityTests {
|
||||
// MARK: - 自动默认(跟随硬件键盘在场)
|
||||
|
||||
@Test("硬件键盘在场 + 无用户覆盖 → 隐藏(软 KeyBar 冗余)")
|
||||
func hardwarePresentAutoHides() {
|
||||
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: true, userOverride: nil) == false)
|
||||
}
|
||||
|
||||
@Test("无硬件键盘 + 无用户覆盖 → 显示(iPhone 默认,零回归)")
|
||||
func noHardwareAutoShows() {
|
||||
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: false, userOverride: nil) == true)
|
||||
}
|
||||
|
||||
// MARK: - 用户覆盖压过自动
|
||||
|
||||
@Test("用户强制显示压过硬件在场的自动隐藏")
|
||||
func userForceShowWinsOverHardware() {
|
||||
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: true, userOverride: true) == true)
|
||||
}
|
||||
|
||||
@Test("用户强制隐藏压过无硬件的自动显示")
|
||||
func userForceHideWinsOverNoHardware() {
|
||||
#expect(KeyBarVisibility.isVisible(hardwareKeyboardPresent: false, userOverride: false) == false)
|
||||
}
|
||||
}
|
||||
28
ios/App/WebTermTests/LayoutPolicyTests.swift
Normal file
28
ios/App/WebTermTests/LayoutPolicyTests.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iPad-2 · 自适应根视图的唯一 size-class 决策点(仿 `PrivacyShadePolicy`
|
||||
/// 的纯谓词先例)。regular 宽度 → 分栏(sidebar+detail);compact/未知 →
|
||||
/// 现有 iPhone stack 路径。这是**唯一**判据 —— 视图里严禁散落
|
||||
/// `if sizeClass == …`(PLAN_IOS_IPAD §4)。
|
||||
///
|
||||
/// 覆盖 `UserInterfaceSizeClass` 的三种可能取值(.regular / .compact / nil),
|
||||
/// 后者是「size class 尚未确定」的最保守回退:退到现有已测路径。
|
||||
@Suite("LayoutPolicy (T-iPad-2)")
|
||||
struct LayoutPolicyTests {
|
||||
@Test("regular 宽度 → split(iPad 全屏/大分屏:sidebar + detail)")
|
||||
func regularIsSplit() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
|
||||
}
|
||||
|
||||
@Test("compact 宽度 → stack(iPhone / iPad Slide Over:字节级复用现有路径)")
|
||||
func compactIsStack() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
|
||||
}
|
||||
|
||||
@Test("nil(size class 未定)→ stack(最保守:退现有 iPhone 路径)")
|
||||
func nilIsStack() {
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: nil) == .stack)
|
||||
}
|
||||
}
|
||||
75
ios/App/WebTermTests/ProjectsLayoutTests.swift
Normal file
75
ios/App/WebTermTests/ProjectsLayoutTests.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iPad-4 · Projects 大屏化的纯布局决策单测。判据是**设备 idiom + 可用宽度**
|
||||
/// (单点,仿 `LayoutPolicy` 先例)——见 `ProjectsLayout` 注释解释为何用 idiom
|
||||
/// 而非 size class(iPad 的 Projects 表单 sheet 内部恒 compact,与 iPhone 横屏
|
||||
/// 无异,size class 无法在不回归 iPhone 的前提下区分二者)。
|
||||
///
|
||||
/// iPhone 零回归的可测护栏:`.phone` 分支必须与「现状」逐字节等价——列数恒
|
||||
/// 为 1(现有单列 List)、不走网格、sheet detents 恒为 nil(不加修饰符 = 现有
|
||||
/// 默认全高 sheet)。`.pad` 分支才是新增的大屏行为。
|
||||
@Suite("ProjectsGridLayout column count (T-iPad-4)")
|
||||
struct ProjectsGridLayoutTests {
|
||||
// MARK: - iPhone → 恒 1 列 / 不走网格(字节级不变)
|
||||
|
||||
@Test(".phone → 恒 1 列,宽度无关(含横屏宽尺寸,现有单列 List 原样复用)")
|
||||
func phoneIsAlwaysSingleColumn() {
|
||||
#expect(ProjectsGridLayout.columnCount(availableWidth: 320, idiom: .phone) == 1)
|
||||
// iPhone 横屏很宽(~852)也绝不多列 —— 零回归的关键护栏。
|
||||
#expect(ProjectsGridLayout.columnCount(availableWidth: 852, idiom: .phone) == 1)
|
||||
#expect(ProjectsGridLayout.columnCount(availableWidth: 2000, idiom: .phone) == 1)
|
||||
}
|
||||
|
||||
@Test(".phone 不走网格;.pad 走网格")
|
||||
func usesGridOnlyOnPad() {
|
||||
#expect(ProjectsGridLayout.usesGrid(idiom: .phone) == false)
|
||||
#expect(ProjectsGridLayout.usesGrid(idiom: .pad) == true)
|
||||
}
|
||||
|
||||
// MARK: - iPad → 按可用宽度 1/2/3 列
|
||||
|
||||
@Test(".pad 且窄于两列阈值 → 1 列(极窄 iPad 多任务,绝不挤压)")
|
||||
func padNarrowIsSingleColumn() {
|
||||
#expect(ProjectsGridLayout.columnCount(
|
||||
availableWidth: ProjectsGridLayout.twoColumnMinWidth - 1, idiom: .pad) == 1)
|
||||
}
|
||||
|
||||
@Test(".pad 且在 [两列阈值, 三列阈值) → 2 列(表单 sheet 内宽即此档)")
|
||||
func padMediumIsTwoColumns() {
|
||||
#expect(ProjectsGridLayout.columnCount(
|
||||
availableWidth: ProjectsGridLayout.twoColumnMinWidth, idiom: .pad) == 2)
|
||||
#expect(ProjectsGridLayout.columnCount(
|
||||
availableWidth: ProjectsGridLayout.threeColumnMinWidth - 1, idiom: .pad) == 2)
|
||||
}
|
||||
|
||||
@Test(".pad 且 >= 三列阈值 → 3 列(更宽面板/横屏全宽)")
|
||||
func padWideIsThreeColumns() {
|
||||
#expect(ProjectsGridLayout.columnCount(
|
||||
availableWidth: ProjectsGridLayout.threeColumnMinWidth, idiom: .pad) == 3)
|
||||
#expect(ProjectsGridLayout.columnCount(availableWidth: 1400, idiom: .pad) == 3)
|
||||
}
|
||||
|
||||
@Test("列数恒 >= 1(任何宽度都不返回 0/负,防空网格崩溃)")
|
||||
func columnCountIsAlwaysPositive() {
|
||||
for width in stride(from: CGFloat(0), through: 2000, by: 137) {
|
||||
#expect(ProjectsGridLayout.columnCount(availableWidth: width, idiom: .pad) >= 1)
|
||||
#expect(ProjectsGridLayout.columnCount(availableWidth: width, idiom: .phone) == 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ProjectsSheetSizing (T-iPad-4)")
|
||||
struct ProjectsSheetSizingTests {
|
||||
@Test(".phone → nil(不加 detent 修饰符 = 现有默认全高 sheet,字节级不变)")
|
||||
func phoneIsNil() {
|
||||
#expect(ProjectsSheetSizing.detents(idiom: .phone) == nil)
|
||||
}
|
||||
|
||||
@Test(".pad → [.medium, .large](iPad 卡片式,不铺满大屏;仍可上拉到大)")
|
||||
func padIsCardDetents() {
|
||||
#expect(ProjectsSheetSizing.detents(idiom: .pad) == [.medium, .large])
|
||||
}
|
||||
}
|
||||
305
ios/App/WebTermTests/SidebarSelectionTests.swift
Normal file
305
ios/App/WebTermTests/SidebarSelectionTests.swift
Normal file
@@ -0,0 +1,305 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iPad-2 · split 模式下 sidebar 选中态 ↔ 现有 `AppCoordinator` 路由的**等价
|
||||
/// 映射**证明。分栏 UI 只是「又一个触发面」——它调用的仍是既有的
|
||||
/// `open(id)` / `open(nil)` / `presentProjects()`,绝不新开一条会话生命周期:
|
||||
///
|
||||
/// - 选 `.session(id)` == 现有 `open(id)`:首帧 `attach(sessionId:id, cwd:nil)`;
|
||||
/// - 选 `.newSession` == 现有 `open(nil)`:首帧 `attach(sessionId:nil, cwd:nil)`;
|
||||
/// - 选 `.projects` == 现有 Projects 呈现(`isProjectsPresented` 置位)。
|
||||
///
|
||||
/// 二向绑定:终端经任意路径打开/关闭时,`selectedSidebarItem` 如实反映
|
||||
/// (adopted 后 = `.session(adoptedId)`;关闭后归 nil)。切会话沿用单活 WS 不变式
|
||||
/// 的 close→open(复用 `closeTerminal` 记 last-seen + `open`,**非**平行生命周期)。
|
||||
///
|
||||
/// 真 `SessionEngine`/`AppCoordinator` over FakeTransport/FakeHTTPTransport,
|
||||
/// 零真实等待(openTask + waitUntilProcessed 双屏障,NewSessionInCwdTests 同款)。
|
||||
@MainActor
|
||||
@Suite("SidebarSelection (T-iPad-2)")
|
||||
struct SidebarSelectionTests {
|
||||
private nonisolated static let base = "http://192.168.1.5:3000"
|
||||
|
||||
// MARK: - Fixtures(NewSessionInCwdTests / SessionSwitcherTests 同款)
|
||||
|
||||
private struct Fixture {
|
||||
let transport: FakeTransport
|
||||
let http: FakeHTTPTransport
|
||||
let host: HostRegistry.Host
|
||||
let unreadStore: InMemoryUnreadWatermarkStore
|
||||
let environment: AppEnvironment
|
||||
}
|
||||
|
||||
private func makeFixture(suiteName: String) throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let transport = FakeTransport()
|
||||
let http = FakeHTTPTransport()
|
||||
let unreadStore = InMemoryUnreadWatermarkStore()
|
||||
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
return Fixture(
|
||||
transport: transport,
|
||||
http: http,
|
||||
host: host,
|
||||
unreadStore: unreadStore,
|
||||
environment: AppEnvironment(
|
||||
hostStore: InMemoryHostStore(hosts: [host]),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// activeHost 上线(sidebar 触发面解析主机的唯一来源)。
|
||||
private func primeHost(_ coordinator: AppCoordinator) async {
|
||||
await coordinator.sessionList.reloadHosts()
|
||||
}
|
||||
|
||||
/// attach 握手完成(服务器 adopted):openTask 完成 ∧ 前 3 个事件已应用。
|
||||
private func adopt(
|
||||
_ controller: TerminalSessionController,
|
||||
transport: FakeTransport,
|
||||
sessionId: UUID
|
||||
) async {
|
||||
await controller.openTask?.value
|
||||
await transport.emit(
|
||||
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
|
||||
)
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 3)
|
||||
}
|
||||
|
||||
/// 新 controller 的 open 已提交且连接事件已冲刷(帧必已落地)。
|
||||
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
|
||||
await controller.openTask?.value
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
|
||||
}
|
||||
|
||||
// MARK: - 1. SidebarItem → 路由等价(同一 coordinator API)
|
||||
|
||||
@Test("选 .session(id) == 现有 open(id):首帧 attach(sessionId:id, cwd:nil),单一连接")
|
||||
func sessionItemOpensSameAsOpenId() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.sessionItem")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
let sessionId = UUID()
|
||||
|
||||
// Act:sidebar 选中一行 —— split 只是另一个触发面。
|
||||
coordinator.selectSidebarItem(.session(sessionId))
|
||||
|
||||
// Assert:走的是同一 open → startTerminal 通道,首帧带该 id 的裸 attach。
|
||||
let controller = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(controller)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 1) // 无平行生命周期:只有一个 engine 连接
|
||||
#expect(byConnection[0] == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))])
|
||||
controller.teardown()
|
||||
}
|
||||
|
||||
@Test("选 .newSession == 现有 open(nil):首帧 attach(sessionId:nil, cwd:nil)")
|
||||
func newSessionItemOpensFreshSession() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.newSession")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
|
||||
// Act
|
||||
coordinator.selectSidebarItem(.newSession)
|
||||
|
||||
// Assert
|
||||
let controller = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(controller)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 1)
|
||||
#expect(byConnection[0] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
||||
controller.teardown()
|
||||
}
|
||||
|
||||
@Test("选 .projects == 现有 Projects 呈现:置位 isProjectsPresented,不开终端")
|
||||
func projectsItemPresentsProjects() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.projects")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
|
||||
// Act
|
||||
coordinator.selectSidebarItem(.projects)
|
||||
|
||||
// Assert:复用既有 presentProjects —— 不产生 terminalController、无连接。
|
||||
#expect(coordinator.isProjectsPresented)
|
||||
#expect(coordinator.projectsViewModel != nil)
|
||||
#expect(coordinator.terminalController == nil)
|
||||
let attempts = await fixture.transport.connectAttempts
|
||||
#expect(attempts.isEmpty)
|
||||
}
|
||||
|
||||
@Test("无 activeHost → .session/.newSession 均 no-op(不 spawn、不连接)")
|
||||
func withoutActiveHostIsNoOp() async throws {
|
||||
// Arrange:不 reloadHosts —— activeHost 仍为 nil。
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.noHost")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
|
||||
// Act
|
||||
coordinator.selectSidebarItem(.session(UUID()))
|
||||
coordinator.selectSidebarItem(.newSession)
|
||||
|
||||
// Assert
|
||||
#expect(coordinator.terminalController == nil)
|
||||
let attempts = await fixture.transport.connectAttempts
|
||||
#expect(attempts.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 2. selectedSidebarItem 二向绑定(打开/关闭如实反映)
|
||||
|
||||
@Test("打开终端 → selectedSidebarItem = .session(adoptedId);closeTerminal → 归 nil")
|
||||
func selectionReflectsOpenAndClose() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.reflect")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
let adopted = UUID()
|
||||
|
||||
// 冷态:无选中。
|
||||
#expect(coordinator.selectedSidebarItem == nil)
|
||||
|
||||
// Act:打开新会话并被服务器 adopted。
|
||||
coordinator.selectSidebarItem(.newSession)
|
||||
let controller = try #require(coordinator.terminalController)
|
||||
await adopt(controller, transport: fixture.transport, sessionId: adopted)
|
||||
|
||||
// Assert:选中态如实反映 adopted 会话(detail 面板正在显示它)。
|
||||
#expect(coordinator.selectedSidebarItem == .session(adopted))
|
||||
|
||||
// Act:返回列表(关闭 detail)。
|
||||
coordinator.closeTerminal()
|
||||
|
||||
// Assert:无 detail → 无选中(占位「选择或新建会话」)。
|
||||
#expect(coordinator.selectedSidebarItem == nil)
|
||||
}
|
||||
|
||||
@Test("killCurrentSession:有 adopted 会话 → detach detail(归 nil);无会话 → no-op")
|
||||
func killCurrentSessionClosesDetail() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.kill")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
|
||||
// 无会话时 kill = no-op(不崩、detail 仍空)。
|
||||
coordinator.killCurrentSession()
|
||||
#expect(coordinator.terminalController == nil)
|
||||
|
||||
// Act:打开并 adopt,再 kill。
|
||||
coordinator.selectSidebarItem(.newSession)
|
||||
let controller = try #require(coordinator.terminalController)
|
||||
await adopt(controller, transport: fixture.transport, sessionId: UUID())
|
||||
coordinator.killCurrentSession()
|
||||
|
||||
// Assert:detail 被 detach(kill 走既有 SessionListViewModel.kill →
|
||||
// APIClient.killSession 带 Origin,无新网络路径,由 T-iOS-8 契约测覆盖)。
|
||||
#expect(coordinator.terminalController == nil)
|
||||
#expect(coordinator.selectedSidebarItem == nil)
|
||||
}
|
||||
|
||||
@Test("sidebarSelection 绑定 get/set 往返:set(.newSession) 触发打开;get 反映 adopted")
|
||||
func bindingRoundTrips() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.binding")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
let adopted = UUID()
|
||||
let binding = coordinator.sidebarSelection
|
||||
|
||||
// Act:经绑定 setter 选中(== NavigationSplitView 的选中回调)。
|
||||
binding.wrappedValue = .newSession
|
||||
let controller = try #require(coordinator.terminalController)
|
||||
await adopt(controller, transport: fixture.transport, sessionId: adopted)
|
||||
|
||||
// Assert:getter 反映 adopted 会话。
|
||||
#expect(binding.wrappedValue == .session(adopted))
|
||||
controller.teardown()
|
||||
}
|
||||
|
||||
// MARK: - 3. 切会话:close→open 复用(非平行生命周期)
|
||||
|
||||
@Test("sidebar 选另一会话 → closeTerminal(记 last-seen)+open:新 controller、第二连接 attach B")
|
||||
func switchingSessionsReusesCloseThenOpen() async throws {
|
||||
// Arrange:先打开会话 A 并 adopted。
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.switch")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
let sessionA = UUID()
|
||||
let sessionB = UUID()
|
||||
coordinator.selectSidebarItem(.session(sessionA))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: sessionA)
|
||||
|
||||
// Act:sidebar 选中会话 B(detail 即时切换)。
|
||||
coordinator.selectSidebarItem(.session(sessionB))
|
||||
|
||||
// Assert:单活 WS —— close(A)→open(B),新 controller、第二连接 attach B。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
#expect(second !== first)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 2)
|
||||
#expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: sessionB, cwd: nil))])
|
||||
// 复用 closeTerminal —— A 的 last-seen 水位已记(切走即已读)。
|
||||
#expect(fixture.unreadStore.snapshot[sessionA] != nil)
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("重复选中当前 detail 会话 → no-op(不 close/open,无第二连接)")
|
||||
func reselectingCurrentSessionIsNoOp() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.reselect")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
let sessionA = UUID()
|
||||
coordinator.selectSidebarItem(.session(sessionA))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: sessionA)
|
||||
|
||||
// Act:再点同一行。
|
||||
coordinator.selectSidebarItem(.session(sessionA))
|
||||
|
||||
// Assert:controller 不换、无第二连接(避免无谓 churn)。
|
||||
#expect(coordinator.terminalController === first)
|
||||
let attempts = await fixture.transport.connectAttempts
|
||||
#expect(attempts.count == 1)
|
||||
first.teardown()
|
||||
}
|
||||
|
||||
// MARK: - 4. 布局切换与会话生命周期正交(T-iOS-29 .id 稳定性)
|
||||
|
||||
@Test("regular→compact 切换(Slide Over):controller 归 coordinator 所有,实例不变、选中态保持")
|
||||
func controllerIdentityStableAcrossLayoutSwitch() async throws {
|
||||
// Arrange:打开并 adopted。
|
||||
let fixture = try makeFixture(suiteName: "SidebarSelectionTests.identity")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
await primeHost(coordinator)
|
||||
let adopted = UUID()
|
||||
coordinator.selectSidebarItem(.newSession)
|
||||
let captured = try #require(coordinator.terminalController)
|
||||
await adopt(captured, transport: fixture.transport, sessionId: adopted)
|
||||
|
||||
// Act:模拟 iPad 拉出/推回 Slide Over —— AdaptiveRootView 只换渲染的
|
||||
// 根子视图(split↔stack),会话状态归 coordinator 所有,与布局正交。
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
|
||||
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
|
||||
|
||||
// Assert:controller 实例不变(复用 T-iOS-29 的 .id(controller.id) 稳定性
|
||||
// 前提:controller 不因布局分支切换而重建)、选中态如常。
|
||||
#expect(coordinator.terminalController === captured)
|
||||
#expect(coordinator.selectedSidebarItem == .session(adopted))
|
||||
captured.teardown()
|
||||
}
|
||||
}
|
||||
139
ios/App/WebTermTests/TerminalContextMenuTests.swift
Normal file
139
ios/App/WebTermTests/TerminalContextMenuTests.swift
Normal file
@@ -0,0 +1,139 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user