import Foundation import SessionCore import SwiftTerm import TestSupport import Testing import UIKit import WireProtocol @testable import WebTerm /// T-iOS-33 · 终端内搜索(ios-completion §4 RED 清单 1–18)。 /// /// 契约来源:SwiftTerm 1.13.0 自带的搜索 API(`findNext`/`findPrevious`/ /// `clearSearch`,`SearchOptions` 默认与 xterm.js search addon 一致)+ web 参照 /// `public/search.ts`(Enter=next / Shift+Enter=prev / Esc=关闭并 clear)。 /// /// 铁律:搜索是**纯读** —— 整个流程不得产生一个 PTY 字节(byte-shuttle 不变式)。 @MainActor @Suite("TerminalSearch (T-iOS-33)") struct TerminalSearchTests { // MARK: - Fakes /// 记录调用序列的搜索引擎替身:命中与否由测试注入。 @MainActor private final class FakeSearcher: TerminalSearching { enum Call: Equatable { case next(String) case previous(String) case clear } private(set) var calls: [Call] = [] var hitResult = true func searchNext(term: String) -> Bool { calls = calls + [.next(term)] return hitResult } func searchPrevious(term: String) -> Bool { calls = calls + [.previous(term)] return hitResult } func searchClear() { calls = calls + [.clear] } } private func model(_ searcher: FakeSearcher) -> TerminalSearchModel { let model = TerminalSearchModel() model.attach(searcher) return model } // MARK: - A. 查询提交策略(1–3) @Test("1 · 空串不可提交,且零引擎调用") func emptyQueryIsNotSubmittable() { #expect(!TerminalSearchQuery.isSubmittable("")) let searcher = FakeSearcher() let model = model(searcher) model.query = "" model.find(.next) #expect(searcher.calls.isEmpty) #expect(model.outcome == .idle) } @Test("2 · 单个空格可提交(镜像 web:空格是合法搜索词,绝不 trim 掉)") func singleSpaceIsSubmittable() { #expect(TerminalSearchQuery.isSubmittable(" ")) let searcher = FakeSearcher() let model = model(searcher) model.query = " " model.find(.next) #expect(searcher.calls == [.next(" ")]) } @Test("3 · 查询词逐字符原样下传(不 trim、不改大小写)") func queryIsPassedThroughVerbatim() { let searcher = FakeSearcher() let model = model(searcher) model.query = " Foo " model.find(.next) #expect(searcher.calls == [.next(" Foo ")]) } // MARK: - B. 方向与引擎调用(4–9) @Test("4 · find(.next) → 恰一次 searchNext,零 searchPrevious") func findNextCallsNextOnly() { let searcher = FakeSearcher() let model = model(searcher) model.query = "claude" model.find(.next) #expect(searcher.calls == [.next("claude")]) } @Test("5 · find(.previous) → 恰一次 searchPrevious,零 searchNext") func findPreviousCallsPreviousOnly() { let searcher = FakeSearcher() let model = model(searcher) model.query = "claude" model.find(.previous) #expect(searcher.calls == [.previous("claude")]) } @Test("6 · 引擎命中 → outcome == .found") func hitProducesFoundOutcome() { let searcher = FakeSearcher() searcher.hitResult = true let model = model(searcher) model.query = "claude" model.find(.next) #expect(model.outcome == .found) } @Test("7 · 引擎未命中 → outcome == .notFound,且有非空文案") func missProducesNotFoundOutcomeWithCopy() { let searcher = FakeSearcher() searcher.hitResult = false let model = model(searcher) model.query = "zzz" model.find(.next) #expect(model.outcome == .notFound) let status = model.statusText #expect(status != nil) #expect(!(status ?? "").isEmpty) } @Test("8 · 连续三次 find(.next) → 三次引擎调用(游标由 SwiftTerm 推进)") func repeatedFindHitsEngineEachTime() { let searcher = FakeSearcher() let model = model(searcher) model.query = "x" model.find(.next) model.find(.next) model.find(.next) #expect(searcher.calls == [.next("x"), .next("x"), .next("x")]) } @Test("9 · 未 attach 引擎 → 不崩,outcome 保持 .idle") func noSearcherAttachedIsSafe() { let model = TerminalSearchModel() model.query = "claude" model.find(.next) #expect(model.outcome == .idle) #expect(model.statusText == nil) } // MARK: - C. 打开/关闭生命周期(10–14) @Test("10 · 初始 isPresented == false 且 outcome == .idle") func initialStateIsClosedAndIdle() { let model = TerminalSearchModel() #expect(!model.isPresented) #expect(model.outcome == .idle) } @Test("11 · present()/dismiss() 切换 isPresented") func presentAndDismissTogglePresentation() { let searcher = FakeSearcher() let model = model(searcher) model.present() #expect(model.isPresented) model.dismiss() #expect(!model.isPresented) } @Test("12 · dismiss() → 恰一次 searchClear + 清空 query + outcome 复位") func dismissClearsHighlightAndQuery() { let searcher = FakeSearcher() searcher.hitResult = false let model = model(searcher) model.present() model.query = "zzz" model.find(.next) #expect(model.outcome == .notFound) model.dismiss() #expect(searcher.calls == [.next("zzz"), .clear]) #expect(model.query.isEmpty) #expect(model.outcome == .idle) } @Test("13 · present() 不调用任何引擎方法(开面板 ≠ 搜索)") func presentDoesNotTouchEngine() { let searcher = FakeSearcher() let model = model(searcher) model.present() #expect(searcher.calls.isEmpty) } @Test("14 · 改 query → outcome 复位 .idle(旧「无匹配」不挂新词)") func editingQueryResetsOutcome() { let searcher = FakeSearcher() searcher.hitResult = false let model = model(searcher) model.query = "zzz" model.find(.next) #expect(model.outcome == .notFound) model.query = "zz" #expect(model.outcome == .idle) #expect(model.statusText == nil) } // MARK: - D. 不变式:搜索是纯读(15–16) @Test("15 · 整个搜索流程后零 PTY 字节(byte-shuttle 不变式)") func searchNeverWritesToThePty() async throws { // Arrange:真 SessionEngine over FakeTransport + 真 TerminalViewModel。 let transport = FakeTransport() let clock = FakeClock() let baseURL = try #require(URL(string: "http://192.168.1.5:3000")) let endpoint = try #require(HostEndpoint(baseURL: baseURL)) let engine = SessionEngine( transport: transport, clock: clock, endpoint: endpoint, eventsSource: { _ in [] } ) let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events) terminalViewModel.start() // Act:开面板 → 搜前后 → 关面板。 let searcher = FakeSearcher() let model = model(searcher) model.present() model.query = "claude" model.find(.next) model.find(.previous) model.dismiss() // Assert:一个字节都没进 PTY。 #expect(terminalViewModel.forwardedSendCount == 0) #expect(terminalViewModel.droppedReadOnlyInputCount == 0) } @Test("16 · 终端已退出(read-only)时搜索照常可用") func searchWorksOnAnExitedTerminal() async throws { // Arrange:把 VM 推到 .exited。 let transport = FakeTransport() let clock = FakeClock() let baseURL = try #require(URL(string: "http://192.168.1.5:3000")) let endpoint = try #require(HostEndpoint(baseURL: baseURL)) let engine = SessionEngine( transport: transport, clock: clock, endpoint: endpoint, eventsSource: { _ in [] } ) let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events) terminalViewModel.start() await engine.open(sessionId: nil, cwd: nil) await transport.emit(frame: #"{"type":"exit","code":0,"reason":"done"}"#) await terminalViewModel.waitUntilProcessed(eventCount: 3) #expect(terminalViewModel.isReadOnly) // Act let searcher = FakeSearcher() let model = model(searcher) model.query = "claude" model.find(.next) // Assert:搜索是纯读,退出后仍然可用。 #expect(searcher.calls == [.next("claude")]) #expect(model.outcome == .found) } // MARK: - E. SwiftTerm 绑定(17–18,Accept:命中并高亮) /// 真 `KeyCommandTerminalView`(非替身):喂一段输出,再用 `TerminalSearching` /// 的三个方法打 SwiftTerm 自带搜索,命中即选区高亮。 @Test("17 · 真 SwiftTerm view:命中返回 true 并高亮选区;未命中返回 false") func realTerminalViewSearchHitsAndHighlights() { let terminal = KeyCommandTerminalView( frame: CGRect(x: 0, y: 0, width: 480, height: 480) ) terminal.feed(text: "claude code cockpit\r\n") let searcher: any TerminalSearching = terminal #expect(searcher.searchNext(term: "cockpit")) #expect(terminal.hasActiveSelection) #expect(!searcher.searchNext(term: "nonexistent-needle")) } @Test("18 · searchClear() 清掉高亮") func clearRemovesHighlight() { let terminal = KeyCommandTerminalView( frame: CGRect(x: 0, y: 0, width: 480, height: 480) ) terminal.feed(text: "claude code cockpit\r\n") let searcher: any TerminalSearching = terminal #expect(searcher.searchNext(term: "cockpit")) #expect(terminal.hasActiveSelection) searcher.searchClear() #expect(!terminal.hasActiveSelection) } }