Files
web-terminal/ios/App/WebTermTests/SidebarSelectionTests.swift
Yaojia Wang 823432b1c8 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/
2026-07-05 19:58:30 +02:00

306 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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