import Foundation import SessionCore import TestSupport import Testing import WireProtocol @testable import WebTerm /// T-iOS-25 · Quick-reply chips + 常用语面板。 /// /// 覆盖面(Steps 测试先行): /// - 内置 chips 逐项镜像 web `public/quick-reply.ts` `BUILT_IN_CHIPS` /// (id/text/label/appendEnter 与顺序;Esc 字节经 `KeyByteMap` 解析—— /// App 层禁止手写转义序列); /// - chip 点击 → `input` 帧(文本 + `\r`,Enter 是 0x0D 不是 0x0A), /// 复用 TerminalViewModel 的有序发送泵:快速连点 = 按序两帧、绝不交错; /// - 自定义面板 CRUD + 重排:纯不可变操作(镜像 add/remove/reorder/update /// Chip),UserDefaults 持久化(可注入 suite;非机密,不进 Keychain), /// 越界重排返回原样、损坏/异形存档 → 逐项过滤而非 crash(存储边界不可信); /// - 浮出时机:仅 waiting(SessionEvent 流上的持牌 gate——status waiting /// pending=true 的流内投影)才显示;gate 解除、exited/failed(read-only) /// → 隐藏。 @MainActor @Suite("QuickReply") struct QuickReplyTests { // MARK: - Fixtures private enum ServerFrames { static func attached(_ id: UUID) -> String { #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"# } static func status(pending: Bool, gate: String? = nil) -> String { var fields = ["\"type\":\"status\"", "\"status\":\"waiting\"", "\"pending\":\(pending)"] if let gate { fields.append("\"gate\":\"\(gate)\"") } return "{\(fields.joined(separator: ","))}" } static func exit(code: Int) -> String { #"{"type":"exit","code":\#(code)}"# } } private static func chip( id: String = "user_t", text: String = "yes", label: String = "yes", appendEnter: Bool = true ) -> QuickReplyChip { QuickReplyChip(id: id, text: text, label: label, appendEnter: appendEnter) } /// 每测试独立的 UserDefaults suite(模式同 LastSessionStoreTests)。 private static func makeSuiteName() -> String { "quick-reply-tests-\(UUID().uuidString)" } @MainActor private final class NoopHaptics: HapticSignaling { func gateDidArrive() {} } // MARK: - Harness(真 engine + 生产同款 fan-out 双分支接线) @MainActor private final class Harness { let transport = FakeTransport() let clock = FakeClock() let engine: SessionEngine let fanOut: EventFanOut let terminalViewModel: TerminalViewModel let gateViewModel: GateViewModel init() throws { let baseURL = try #require(URL(string: "http://192.168.1.5:3000")) let endpoint = try #require(HostEndpoint(baseURL: baseURL)) engine = SessionEngine( transport: transport, clock: clock, endpoint: endpoint, eventsSource: { _ in [] } ) fanOut = EventFanOut(source: engine.events, branchCount: 2) terminalViewModel = TerminalViewModel(engine: engine, events: fanOut.branches[0]) gateViewModel = GateViewModel( engine: engine, events: fanOut.branches[1], haptics: NoopHaptics(), clock: clock ) terminalViewModel.start() gateViewModel.start() } /// 标准前奏:open → .connecting → .connected → attached(3 事件到两个 VM)。 func openAndAdopt(_ id: UUID = UUID()) async { await engine.open(sessionId: nil, cwd: nil) await transport.emit(frame: ServerFrames.attached(id)) await waitBothProcessed(eventCount: 3) } /// 两条 fan-out 分支各自达到事件水位(可见性由两个 VM 共同驱动)。 func waitBothProcessed(eventCount: Int) async { await terminalViewModel.waitUntilProcessed(eventCount: eventCount) await gateViewModel.waitUntilProcessed(eventCount: eventCount) } var isBarVisible: Bool { QuickReplyBar.isVisible( gate: gateViewModel.currentGate, isReadOnly: terminalViewModel.isReadOnly ) } /// 连接 0 上客户端发出的帧(attach 先行,随后 input)。 func wireFrames() async -> [String] { let byConnection = await transport.sentFramesByConnection return byConnection.first ?? [] } } // MARK: - 内置 chips(镜像 public/quick-reply.ts BUILT_IN_CHIPS) @Test("内置 chips 逐项镜像 web:id/text/label/appendEnter 与顺序完全一致") func builtInChipsMirrorWebDefaults() { let chips = QuickReplyPalette.builtInChips #expect(chips.map(\.id) == ["__yes", "__continue", "__1", "__2", "__3", "__esc"]) #expect(chips.map(\.text) == [ "yes", "continue", "1", "2", "3", KeyByteMap.bytes(for: .esc), ]) #expect(chips.map(\.label) == ["yes", "continue", "1", "2", "3", "Esc"]) #expect(chips.map(\.appendEnter) == [true, true, true, true, true, false]) } @Test("payload:appendEnter → 文本 + Enter(\\r,0x0D——CLAUDE.md gotcha);否则原文") func payloadComposition() { let enter = Self.chip(text: "continue", appendEnter: true) #expect(QuickReplyPalette.payload(for: enter) == "continue" + KeyByteMap.bytes(for: .enter)) #expect(QuickReplyPalette.payload(for: enter) == "continue\r") let bare = Self.chip(text: KeyByteMap.bytes(for: .esc), appendEnter: false) #expect(QuickReplyPalette.payload(for: bare) == KeyByteMap.bytes(for: .esc)) } // MARK: - 纯不可变 CRUD(镜像 addChip/removeChip/reorderChip/updateChip) @Test("adding 追加到末尾;removing 按 id 过滤;未知 id 原样") func addingAndRemoving() { let a = Self.chip(id: "user_a") let b = Self.chip(id: "user_b") let added = QuickReplyPalette.adding([a], b) #expect(added == [a, b]) #expect(QuickReplyPalette.removing(added, id: "user_a") == [b]) #expect(QuickReplyPalette.removing(added, id: "user_zzz") == [a, b]) } @Test("reordering:前移/后移语义同 web splice;任一索引越界 → 原样返回") func reorderingMovesAndBoundsChecks() { let a = Self.chip(id: "user_a") let b = Self.chip(id: "user_b") let c = Self.chip(id: "user_c") let chips = [a, b, c] #expect(QuickReplyPalette.reordering(chips, fromIndex: 0, toIndex: 2) == [b, c, a]) #expect(QuickReplyPalette.reordering(chips, fromIndex: 2, toIndex: 0) == [c, a, b]) #expect(QuickReplyPalette.reordering(chips, fromIndex: 3, toIndex: 0) == chips) #expect(QuickReplyPalette.reordering(chips, fromIndex: 0, toIndex: -1) == chips) } @Test("updating:按 id 浅合并 patch,id 不可变,其余 chip 不受影响;未知 id 原样") func updatingPatchesMatchingChipOnly() { let a = Self.chip(id: "user_a", text: "yes", label: "yes", appendEnter: true) let b = Self.chip(id: "user_b", text: "go", label: "Go", appendEnter: false) let updated = QuickReplyPalette.updating( [a, b], id: "user_a", text: "no", label: "No" ) #expect(updated == [ QuickReplyChip(id: "user_a", text: "no", label: "No", appendEnter: true), b, ]) #expect(QuickReplyPalette.updating([a, b], id: "user_zzz", text: "x") == [a, b]) } // MARK: - Store:UserDefaults 持久化(可注入 suite) @Test("addChip:修剪空白、label 空则取 text、id 带 user_ 前缀且唯一;跨实例持久") func addPersistsAcrossInstances() throws { let suiteName = Self.makeSuiteName() let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let store = QuickReplyStore(defaults: defaults) #expect(store.addChip(text: " 跑测试 ", label: " ", appendEnter: true)) #expect(store.addChip(text: "git status", label: "状态", appendEnter: false)) let chips = store.userChips #expect(chips.map(\.text) == ["跑测试", "git status"]) #expect(chips.map(\.label) == ["跑测试", "状态"]) #expect(chips.map(\.appendEnter) == [true, false]) #expect(chips.allSatisfy { $0.id.hasPrefix(QuickReplyPalette.userChipIdPrefix) }) #expect(chips[0].id != chips[1].id) // 全部 chips = 内置在前 + 自定义在后(web render 顺序)。 #expect(store.allChips == QuickReplyPalette.builtInChips + chips) // 新实例(同 suite)读回同一面板。 let reloaded = QuickReplyStore(defaults: defaults) #expect(reloaded.userChips == chips) } @Test("addChip:空文本(含纯空白)是 no-op,返回 false 且不持久化") func emptyTextAddIsNoOp() throws { let suiteName = Self.makeSuiteName() let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let store = QuickReplyStore(defaults: defaults) #expect(!store.addChip(text: " ", label: "标签", appendEnter: true)) #expect(store.userChips.isEmpty) #expect(QuickReplyStore(defaults: defaults).userChips.isEmpty) } @Test("remove/update/reorder 均持久化:新实例读回操作后的面板") func mutationsPersistAcrossInstances() throws { let suiteName = Self.makeSuiteName() let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let store = QuickReplyStore(defaults: defaults) #expect(store.addChip(text: "a", label: "A", appendEnter: true)) #expect(store.addChip(text: "b", label: "B", appendEnter: true)) #expect(store.addChip(text: "c", label: "C", appendEnter: true)) let ids = store.userChips.map(\.id) // reorder: A,B,C → B,C,A;update B 的 label;remove C。 store.moveChip(fromIndex: 0, toIndex: 2) store.updateChip(id: ids[1], label: "B改") store.removeChip(id: ids[2]) let expected = [ QuickReplyChip(id: ids[1], text: "b", label: "B改", appendEnter: true), QuickReplyChip(id: ids[0], text: "a", label: "A", appendEnter: true), ] #expect(store.userChips == expected) #expect(QuickReplyStore(defaults: defaults).userChips == expected) } @Test("SwiftUI onMove 适配(IndexSet + 目的地约定)→ 与纯 reordering 同结果") func onMoveAdapterMatchesReorderingSemantics() throws { let suiteName = Self.makeSuiteName() let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } let store = QuickReplyStore(defaults: defaults) #expect(store.addChip(text: "a", label: "A", appendEnter: true)) #expect(store.addChip(text: "b", label: "B", appendEnter: true)) #expect(store.addChip(text: "c", label: "C", appendEnter: true)) // List.onMove:把第 0 行拖到末尾 → fromOffsets {0}, toOffset 3。 store.moveChips(fromOffsets: IndexSet(integer: 0), toOffset: 3) #expect(store.userChips.map(\.text) == ["b", "c", "a"]) } @Test("损坏存档 → 空面板;异形数组 → 逐项过滤只留合法 chip(存储边界不可信)") func corruptOrPartiallyInvalidArchiveIsFiltered() throws { let suiteName = Self.makeSuiteName() let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } // 整体损坏(非 JSON)→ []。 defaults.set(Data("not json at all".utf8), forKey: QuickReplyStore.paletteDefaultsKey) #expect(QuickReplyStore(defaults: defaults).userChips.isEmpty) // 根不是数组 → []。 defaults.set(Data(#"{"id":"user_a"}"#.utf8), forKey: QuickReplyStore.paletteDefaultsKey) #expect(QuickReplyStore(defaults: defaults).userChips.isEmpty) // 数组里混入垃圾条目 → 只留形状完整的(镜像 web isValidChip filter)。 let mixed = #""" [ {"id":"user_a","text":"yes","label":"yes","appendEnter":true}, {"id":42,"text":"x","label":"x","appendEnter":true}, "junk", {"id":"user_b","text":"go","label":"Go"}, {"id":"user_c","text":"go","label":"Go","appendEnter":false} ] """# defaults.set(Data(mixed.utf8), forKey: QuickReplyStore.paletteDefaultsKey) #expect(QuickReplyStore(defaults: defaults).userChips == [ QuickReplyChip(id: "user_a", text: "yes", label: "yes", appendEnter: true), QuickReplyChip(id: "user_c", text: "go", label: "Go", appendEnter: false), ]) } // MARK: - 浮出时机(waiting = 流上的持牌 gate;read-only → 隐藏) @Test("无 gate → 隐藏;gate 升起(tool/plan 均可)→ 浮出;gate 解除 → 隐藏") func visibilityFollowsHeldGate() async throws { let harness = try Harness() await harness.openAndAdopt() #expect(!harness.isBarVisible) await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool")) await harness.waitBothProcessed(eventCount: 4) #expect(harness.isBarVisible) await harness.transport.emit(frame: ServerFrames.status(pending: false)) await harness.waitBothProcessed(eventCount: 5) #expect(!harness.isBarVisible) await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan")) await harness.waitBothProcessed(eventCount: 6) #expect(harness.isBarVisible) } @Test("exited(read-only)→ 即使 gate 仍持牌也隐藏") func exitedTerminalHidesChipsEvenWithHeldGate() async throws { let harness = try Harness() await harness.openAndAdopt() await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool")) await harness.waitBothProcessed(eventCount: 4) #expect(harness.isBarVisible) await harness.transport.emit(frame: ServerFrames.exit(code: 0)) await harness.waitBothProcessed(eventCount: 5) #expect(harness.terminalViewModel.isReadOnly) #expect(!harness.isBarVisible) } // MARK: - 发送路径(经 TerminalViewModel 有序泵 → engine → wire) @Test("chip 点击 → input 帧(文本+\\r)经有序泵上线") func chipTapSendsInputFrame() async throws { let harness = try Harness() await harness.openAndAdopt() let yes = QuickReplyPalette.builtInChips[0] QuickReplyBar.send(yes, through: harness.terminalViewModel) await harness.terminalViewModel.waitUntilForwarded(sendCount: 1) #expect(await harness.wireFrames() == [ MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), MessageCodec.encode(.input(data: "yes\r")), ]) } @Test("快速连点:两帧按点击顺序上线,绝不交错(复用 VM 发送泵)") func rapidDoubleTapStaysOrdered() async throws { let harness = try Harness() await harness.openAndAdopt() let yes = QuickReplyPalette.builtInChips[0] let one = QuickReplyPalette.builtInChips[2] QuickReplyBar.send(yes, through: harness.terminalViewModel) QuickReplyBar.send(one, through: harness.terminalViewModel) await harness.terminalViewModel.waitUntilForwarded(sendCount: 2) #expect(await harness.wireFrames() == [ MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), MessageCodec.encode(.input(data: "yes\r")), MessageCodec.encode(.input(data: "1\r")), ]) } @Test("exited 后点击 → VM read-only 守卫丢弃,wire 上无 input 帧") func tapOnExitedTerminalIsDropped() async throws { let harness = try Harness() await harness.openAndAdopt() await harness.transport.emit(frame: ServerFrames.exit(code: 0)) await harness.waitBothProcessed(eventCount: 4) QuickReplyBar.send(QuickReplyPalette.builtInChips[0], through: harness.terminalViewModel) #expect(harness.terminalViewModel.droppedReadOnlyInputCount == 1) #expect(await harness.wireFrames() == [ MessageCodec.encode(.attach(sessionId: nil, cwd: nil)), ]) } // MARK: - 面板文案(中文具名常量) @Test("面板/编辑器文案:中文具名常量在位") func panelCopyConstants() { #expect(QuickReplyPanel.Copy.title == "常用语") #expect(QuickReplyPanel.Copy.addSection == "添加新常用语") #expect(QuickReplyPanel.Copy.customSection == "自定义常用语") #expect(QuickReplyPanel.Copy.textPlaceholder == "要发送的文本") #expect(QuickReplyPanel.Copy.labelPlaceholder == "标签(可选,默认同文本)") #expect(QuickReplyPanel.Copy.appendEnterToggle == "发送后自动回车") #expect(QuickReplyPanel.Copy.addButton == "添加") #expect(QuickReplyPanel.Copy.emptyState == "还没有自定义常用语") #expect(QuickReplyBar.Copy.managePhrases == "管理常用语") } }