import APIClient import Foundation import HostRegistry import SessionCore import TestSupport import Testing import WireProtocol @testable import WebTerm /// T-iOS-23 · 多会话切换器:unread 点(`lastOutputAt` vs 本地水位,经 /// `UnreadLedger` + App 侧 UserDefaults 持久化)+ OSC 标题(SwiftTerm /// `setTerminalTitle` delegate → `TitleSanitizer` → 列表行)。 /// /// 分层: /// - 列表 VM:unread 判定/清除/持久化、标题注册表(净化在两个边界各自执行); /// - TerminalViewModel:delegate 原始标题在此边界净化,adopted 后携 id 上浮; /// - 接线:controller 把 title hook 接到新旧 VM(suspend→resume 重建后不掉线); /// coordinator 的 closeTerminal 记 last-seen 水位、title 流入列表行。 /// 单活 WS 不变式:切会话 = close→open(deep-link 路径 `openDeepLinkedSession` /// 与列表返回→再开共用 `closeTerminal`,一次只有一个 engine —— 见接线用例)。 @MainActor @Suite("SessionSwitcher (T-iOS-23)") struct SessionSwitcherTests { private nonisolated static let base = "http://192.168.1.5:3000" private nonisolated static let nowMs = 1_700_000_100_000 // MARK: - Fixtures private func makeHost() throws -> HostRegistry.Host { let url = try #require(URL(string: Self.base)) let endpoint = try #require(HostEndpoint(baseURL: url)) return HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint) } private func listURL() throws -> URL { try #require(URL(string: "\(Self.base)/live-sessions")) } private func sessionJSON(id: UUID, lastOutputAt: Int? = nil, cwd: String = "/Users/dev/proj") -> String { let lastOutputJSON = lastOutputAt.map { ",\"lastOutputAt\":\($0)" } ?? "" return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000" + ",\"clientCount\":1,\"status\":\"working\",\"exited\":false" + ",\"cwd\":\"\(cwd)\",\"cols\":80,\"rows\":24\(lastOutputJSON)}" } private func listBody(_ entries: [String]) -> Data { Data("[\(entries.joined(separator: ","))]".utf8) } private func makeListViewModel( hosts: [HostRegistry.Host], http: any HTTPTransport, unreadStore: any UnreadWatermarkStore ) -> SessionListViewModel { SessionListViewModel( hostStore: InMemoryHostStore(hosts: hosts), http: http, clock: FakeClock(), unreadStore: unreadStore, nowMs: { Self.nowMs } ) } private func loadOnce(_ viewModel: SessionListViewModel) async { await viewModel.reloadHosts() await viewModel.refresh() } // MARK: - 列表 unread 点(水位判定 + 清除 + 复燃) @Test("lastOutputAt 高于水位 → unread 点亮;markSeen 熄灭;更新的输出再点亮") func unreadDotLifecycle() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let store = InMemoryUnreadWatermarkStore() let sessionId = UUID() let url = try listURL() await http.queueSuccess( url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)]) ) let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store) // Act & Assert: 从未看过 → unread。 await loadOnce(viewModel) #expect(viewModel.rows.first?.isUnread == true) // Act & Assert: 标记已读 → 立即熄灭(不等下一次轮询)。 viewModel.markSeen(sessionId: sessionId) #expect(viewModel.rows.first?.isUnread == false) // Act & Assert: 服务器又有新输出 → 复燃。 await http.queueSuccess( url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs + 5_000)]) ) await viewModel.refresh() #expect(viewModel.rows.first?.isUnread == true) } @Test("lastOutputAt 缺席(旧服务器)→ 绝不点灯") func absentLastOutputAtNeverLights() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let sessionId = UUID() await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)])) let viewModel = makeListViewModel( hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore() ) // Act await loadOnce(viewModel) // Assert #expect(viewModel.rows.first?.isUnread == false) } @Test("markSeen 持久化水位;新 VM(重启模拟)读回后照样已读") func watermarkPersistsAcrossViewModels() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let store = InMemoryUnreadWatermarkStore() let sessionId = UUID() let url = try listURL() let outputAt = Self.nowMs - 1_000 await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)])) let first = makeListViewModel(hosts: [host], http: http, unreadStore: store) await loadOnce(first) // Act: 标记已读 → 存储必须收到水位。 first.markSeen(sessionId: sessionId) #expect(store.snapshot[sessionId] == Self.nowMs) // Arrange: "重启"——新 VM 从同一 store 载入。 await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)])) let second = makeListViewModel(hosts: [host], http: http, unreadStore: store) // Act & Assert await loadOnce(second) #expect(second.rows.first?.isUnread == false) } @Test("点开会话行(openSession)即记 last-seen —— 点即熄灭") func openSessionMarksSeen() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let store = InMemoryUnreadWatermarkStore() let sessionId = UUID() await http.queueSuccess( url: try listURL(), body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)]) ) let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store) await loadOnce(viewModel) #expect(viewModel.rows.first?.isUnread == true) // Act viewModel.openSession(id: sessionId) // Assert: 导航信号照发,同时水位已记、点已熄。 #expect(viewModel.openRequest?.sessionId == sessionId) #expect(viewModel.rows.first?.isUnread == false) #expect(store.snapshot[sessionId] == Self.nowMs) } // MARK: - 列表标题注册表(敌意标题在列表边界再净化一次) @Test("setSessionTitle 净化敌意标题后上行;净化后为空 → 回退 cwd 名(title nil)") func sessionTitleRegistrySanitizes() async throws { // Arrange let http = FakeHTTPTransport() let host = try makeHost() let sessionId = UUID() await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)])) let viewModel = makeListViewModel( hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore() ) await loadOnce(viewModel) #expect(viewModel.rows.first?.title == nil) // Act: RLO + ESC 注入的敌意标题直接打列表接口(不信任上游净化)。 viewModel.setSessionTitle(sessionId: sessionId, title: "npm \u{202E}run\u{1B}[2J build") // Assert #expect(viewModel.rows.first?.title == "npm run[2J build") // Act & Assert: 全零宽标题 → 清空注册表条目,回退 nil。 viewModel.setSessionTitle(sessionId: sessionId, title: "\u{200B}\u{200D}") #expect(viewModel.rows.first?.title == nil) } // MARK: - TerminalViewModel:delegate 边界净化 + adopted 后携 id 上浮 /// TerminalViewModelTests 的同款 harness:真 engine over FakeTransport。 @MainActor private final class TerminalHarness { let transport = FakeTransport() let engine: SessionEngine let viewModel: TerminalViewModel init() throws { let baseURL = try #require(URL(string: SessionSwitcherTests.base)) let endpoint = try #require(HostEndpoint(baseURL: baseURL)) engine = SessionEngine( transport: transport, clock: FakeClock(), endpoint: endpoint, eventsSource: { _ in [] } ) viewModel = TerminalViewModel(engine: engine, events: engine.events) viewModel.start() } func openAndAdopt(_ id: UUID) async { await engine.open(sessionId: nil, cwd: nil) await transport.emit( frame: #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"# ) await viewModel.waitUntilProcessed(eventCount: 3) } } @Test("setTerminalTitle:敌意原始标题在 VM 边界净化,terminalTitle 暴露净化值并携 adopted id 上浮") func terminalTitleSanitizedAndForwarded() async throws { // Arrange let harness = try TerminalHarness() let adopted = UUID() var forwarded: [(UUID, String)] = [] harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) } await harness.openAndAdopt(adopted) // Act: SwiftTerm delegate 原样送来的敌意 OSC 标题。 harness.viewModel.setTerminalTitle(" claude\u{202E}\u{07} · fix bug ") // Assert #expect(harness.viewModel.terminalTitle == "claude · fix bug") #expect(forwarded.count == 1) #expect(forwarded.first?.0 == adopted) #expect(forwarded.first?.1 == "claude · fix bug") } @Test("adopted 前到达的标题被暂存,adopted 后补发一次(不丢、不提前)") func titleBeforeAdoptionIsHeldThenForwarded() async throws { // Arrange let harness = try TerminalHarness() let adopted = UUID() var forwarded: [(UUID, String)] = [] harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) } // Act: 标题先到(防御路径——真实线序 attached 永远在 output 前)。 harness.viewModel.setTerminalTitle("early") #expect(forwarded.isEmpty) await harness.openAndAdopt(adopted) // Assert #expect(forwarded.count == 1) #expect(forwarded.first?.0 == adopted) #expect(forwarded.first?.1 == "early") } @Test("标题被清空(净化后空串)→ terminalTitle 归 nil,上浮空串让注册表清条目") func clearedTitleForwardsEmpty() async throws { // Arrange let harness = try TerminalHarness() let adopted = UUID() var forwarded: [(UUID, String)] = [] harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) } await harness.openAndAdopt(adopted) harness.viewModel.setTerminalTitle("work") // Act harness.viewModel.setTerminalTitle(" ") // Assert #expect(harness.viewModel.terminalTitle == nil) #expect(forwarded.map(\.1) == ["work", ""]) } // MARK: - 接线:controller 挂 hook(重建后不掉)+ coordinator 全链路 private struct WiringFixture { let transport: FakeTransport let http: FakeHTTPTransport let host: HostRegistry.Host let unreadStore: InMemoryUnreadWatermarkStore let environment: AppEnvironment } private func makeWiringFixture(suiteName: String) throws -> WiringFixture { 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 WiringFixture( 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 ) ) } 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) } @Test("controller 把 title hook 接到 VM;suspend→resume 重建后新 VM 依然上浮") func controllerRewiresTitleHookAcrossRebuild() async throws { // Arrange let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.rewire") let adopted = UUID() var forwarded: [(UUID, String)] = [] let controller = TerminalSessionController( host: fixture.host, sessionId: nil, environment: fixture.environment, onPendingChanged: { _, _ in }, onTitleChanged: { forwarded.append(($0, $1)) } ) controller.start() await adopt(controller, transport: fixture.transport, sessionId: adopted) // Act: 第一代 VM 上浮。 controller.terminalViewModel.setTerminalTitle("gen0") // Assert #expect(forwarded.map(\.1) == ["gen0"]) // Act: 后台→前台重建(新 engine + 新 VM),再上浮一次。 controller.suspend() controller.resumeIfNeeded() await adopt(controller, transport: fixture.transport, sessionId: adopted) controller.terminalViewModel.setTerminalTitle("gen1") // Assert: 重建没有弄丢 hook,id 仍是 adopted id。 #expect(forwarded.map(\.1) == ["gen0", "gen1"]) #expect(forwarded.allSatisfy { $0.0 == adopted }) controller.teardown() } @Test("coordinator:closeTerminal 记 last-seen 水位(切会话/返回列表即已读);title 流入列表行") func coordinatorMarksSeenOnCloseAndRoutesTitles() async throws { // Arrange let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.coordinator") let coordinator = AppCoordinator(environment: fixture.environment) let adopted = UUID() await coordinator.sessionList.reloadHosts() // Act: 打开新会话 → 服务器 adopted。 coordinator.open(SessionListViewModel.OpenRequest( id: UUID(), host: fixture.host, sessionId: nil )) let controller = try #require(coordinator.terminalController) await adopt(controller, transport: fixture.transport, sessionId: adopted) // Act: OSC 标题上浮(模拟 SwiftTerm delegate)→ 列表行可见。 controller.terminalViewModel.setTerminalTitle("vim server.ts") await fixture.http.queueSuccess( url: try listURL(), body: listBody([sessionJSON(id: adopted)]) ) await coordinator.sessionList.refresh() #expect(coordinator.sessionList.rows.first?.title == "vim server.ts") // Act: 返回列表(单活 WS 的切会话前半程:close)。 coordinator.closeTerminal() // Assert: adopted 会话的 last-seen 已入库 —— 切换回列表即已读。 #expect(coordinator.terminalController == nil) #expect(fixture.unreadStore.snapshot[adopted] != nil) } } // MARK: - 测试替身(App 层 UnreadWatermarkStore 的内存实现) /// 线程安全的内存水位存储(SessionListViewModelTests 也复用)。 final class InMemoryUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable { private let lock = NSLock() private var stored: [UUID: Int] init(_ initial: [UUID: Int] = [:]) { stored = initial } func load() -> [UUID: Int] { lock.withLock { stored } } func save(_ watermarks: [UUID: Int]) { lock.withLock { stored = watermarks } } var snapshot: [UUID: Int] { lock.withLock { stored } } }