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: PairingViewModel.Probe(verifyHost: { _, _ 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() } }