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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View 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
/// 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 == "管理常用语")
}
}