Files
web-terminal/ios/App/WebTermTests/QuickReplyTests.swift
Yaojia Wang f40b8f9400 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
2026-07-05 16:15:57 +02:00

394 lines
17 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
/// ChipUserDefaults suite Keychain
/// / crash
/// - waitingSessionEvent gatestatus waiting
/// pending=true gate exited/failedread-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 attached3 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 逐项镜像 webid/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("payloadappendEnter → 文本 + Enter\\r0x0D——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 浅合并 patchid 不可变,其余 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: - StoreUserDefaults 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,Aupdate B labelremove 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 = gateread-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("exitedread-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 == "管理常用语")
}
}