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