feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
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
This commit is contained in:
393
ios/App/WebTermTests/QuickReplyTests.swift
Normal file
393
ios/App/WebTermTests/QuickReplyTests.swift
Normal file
@@ -0,0 +1,393 @@
|
||||
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 == "管理常用语")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user