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:
Yaojia Wang
2026-07-05 19:58:30 +02:00
parent 77502ec4fe
commit 823432b1c8
18 changed files with 1594 additions and 84 deletions

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

View File

@@ -0,0 +1,28 @@
import SwiftUI
import Testing
@testable import WebTerm
/// T-iPad-2 · size-class 仿 `PrivacyShadePolicy`
/// regular sidebar+detailcompact/
/// iPhone stack ****
/// `if sizeClass == `PLAN_IOS_IPAD §4
///
/// `UserInterfaceSizeClass` .regular / .compact / nil
/// size class 退退
@Suite("LayoutPolicy (T-iPad-2)")
struct LayoutPolicyTests {
@Test("regular 宽度 → splitiPad 全屏/大分屏sidebar + detail")
func regularIsSplit() {
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
}
@Test("compact 宽度 → stackiPhone / iPad Slide Over字节级复用现有路径")
func compactIsStack() {
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
}
@Test("nilsize class 未定)→ stack最保守退现有 iPhone 路径)")
func nilIsStack() {
#expect(LayoutPolicy.mode(horizontalSizeClass: nil) == .stack)
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
import Testing
import UIKit
@testable import WebTerm
/// T-iPad-4 · Projects ** idiom + **
/// 仿 `LayoutPolicy` `ProjectsLayout` idiom
/// size classiPad Projects sheet compact iPhone
/// size class iPhone
///
/// iPhone `.phone`
/// 1 Listsheet 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])
}
}

View 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
/// closeopen `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: - FixturesNewSessionInCwdTests / 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 adoptedopenTask 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()
// Actsidebar 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-opdetail
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()
// Assertdetail detachkill 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)
// Assertgetter adopted
#expect(binding.wrappedValue == .session(adopted))
controller.teardown()
}
// MARK: - 3. closeopen
@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)
// Actsidebar Bdetail
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))
// Assertcontroller 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 Overcontroller 归 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
// splitstack coordinator
#expect(LayoutPolicy.mode(horizontalSizeClass: .regular) == .split)
#expect(LayoutPolicy.mode(horizontalSizeClass: .compact) == .stack)
// Assertcontroller T-iOS-29 .id(controller.id)
// controller
#expect(coordinator.terminalController === captured)
#expect(coordinator.selectedSidebarItem == .session(adopted))
captured.teardown()
}
}

View 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("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)
}
}