T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
394 lines
17 KiB
Swift
394 lines
17 KiB
Swift
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<SessionEvent>
|
||
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 == "管理常用语")
|
||
}
|
||
}
|