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:
338
ios/App/WebTermTests/DeepLinkRouterTests.swift
Normal file
338
ios/App/WebTermTests/DeepLinkRouterTests.swift
Normal file
@@ -0,0 +1,338 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
|
||||
/// 解析(全字段白名单,任一非法 → .ignore,绝不部分应用)、WEBTERM_GATE push
|
||||
/// payload 的同源校验入口,以及 DeepLinkHandler 的冷启动 stash / 未知 host
|
||||
/// 落配对页 / 忽略计数。
|
||||
@MainActor
|
||||
@Suite("DeepLinkRouter")
|
||||
struct DeepLinkRouterTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// v4 UUID(version nibble=4、variant=8)——通过 Validation.isValidSessionId。
|
||||
private static let validHostId = "11111111-2222-4333-8444-555555555555"
|
||||
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
/// version nibble=1:Foundation 的 UUID(uuidString:) 接受,但服务器的
|
||||
/// SESSION_ID_RE(v4 专属)拒绝——用它钉住"复用 Validation、不再造正则"。
|
||||
private static let v1StyleId = "11111111-2222-1333-8444-555555555555"
|
||||
/// variant nibble=c(合法 v4 要求 8/9/a/b)。
|
||||
private static let badVariantId = "11111111-2222-4333-c444-555555555555"
|
||||
|
||||
private static func url(_ string: String) throws -> URL {
|
||||
try #require(URL(string: string))
|
||||
}
|
||||
|
||||
private static func openURL(
|
||||
host: String = validHostId, join: String = validSessionId
|
||||
) throws -> URL {
|
||||
try url("webterminal://open?host=\(host)&join=\(join)")
|
||||
}
|
||||
|
||||
private static func makeHost(id: UUID) throws -> HostRegistry.Host {
|
||||
let base = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: base))
|
||||
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
|
||||
}
|
||||
|
||||
// MARK: - URL 解析:合法路径
|
||||
|
||||
@Test("合法 open 链接 → .openSession(hostId, sessionId)")
|
||||
func validLinkRoutes() throws {
|
||||
let route = DeepLinkRouter.route(url: try Self.openURL())
|
||||
|
||||
let hostId = try #require(UUID(uuidString: Self.validHostId))
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("大写 UUID → 接受(Validation 大小写不敏感)")
|
||||
func uppercaseUUIDsAccepted() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.openURL(
|
||||
host: Self.validHostId.uppercased(),
|
||||
join: Self.validSessionId.uppercased()
|
||||
)
|
||||
)
|
||||
|
||||
let hostId = try #require(UUID(uuidString: Self.validHostId))
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("未知多余 query 键 → 忽略键本身,链接仍路由")
|
||||
func unknownExtraKeysIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"webterminal://open?host=\(Self.validHostId)&join=\(Self.validSessionId)&utm=x&foo=bar"
|
||||
)
|
||||
)
|
||||
|
||||
let hostId = try #require(UUID(uuidString: Self.validHostId))
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(route == .openSession(hostId: hostId, sessionId: sessionId))
|
||||
}
|
||||
|
||||
// MARK: - URL 解析:白名单拒绝路径(任一非法 → .ignore)
|
||||
|
||||
@Test("scheme 非 webterminal → .ignore")
|
||||
func wrongSchemeIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("https://open?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("action 非 open → .ignore")
|
||||
func wrongActionIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("webterminal://kill?host=\(Self.validHostId)&join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("带非空 path → .ignore(白名单外的形状)")
|
||||
func extraPathIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"webterminal://open/extra?host=\(Self.validHostId)&join=\(Self.validSessionId)"
|
||||
)
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("缺 host 键 → .ignore")
|
||||
func missingHostKeyIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("webterminal://open?join=\(Self.validSessionId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("缺 join 键 → .ignore")
|
||||
func missingJoinKeyIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url("webterminal://open?host=\(Self.validHostId)")
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("重复 host 键 → .ignore(歧义输入绝不部分应用)")
|
||||
func duplicateHostKeyIgnored() throws {
|
||||
let route = DeepLinkRouter.route(
|
||||
url: try Self.url(
|
||||
"webterminal://open?host=\(Self.validHostId)&host=\(Self.validHostId)&join=\(Self.validSessionId)"
|
||||
)
|
||||
)
|
||||
#expect(route == .ignore)
|
||||
}
|
||||
|
||||
@Test("UUID 非 v4(version nibble=1)→ .ignore:钉住复用 Validation")
|
||||
func nonV4UUIDIgnored() throws {
|
||||
#expect(DeepLinkRouter.route(url: try Self.openURL(host: Self.v1StyleId)) == .ignore)
|
||||
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.v1StyleId)) == .ignore)
|
||||
}
|
||||
|
||||
@Test("variant nibble 非 8/9/a/b → .ignore")
|
||||
func badVariantUUIDIgnored() throws {
|
||||
#expect(DeepLinkRouter.route(url: try Self.openURL(join: Self.badVariantId)) == .ignore)
|
||||
}
|
||||
|
||||
@Test("空值 / 垃圾值 / 空 query → .ignore,不 crash")
|
||||
func fuzzedValuesIgnored() throws {
|
||||
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open?host=&join=")) == .ignore)
|
||||
#expect(DeepLinkRouter.route(url: try Self.url("webterminal://open")) == .ignore)
|
||||
#expect(DeepLinkRouter.route(url: try Self.openURL(host: "not-a-uuid")) == .ignore)
|
||||
#expect(
|
||||
DeepLinkRouter.route(url: try Self.openURL(join: "'; DROP TABLE sessions;--")) == .ignore
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Push payload(WEBTERM_GATE,T-iOS-21 复用同一路由)
|
||||
|
||||
@Test("payload 含合法 sessionId(多余键忽略)→ .gateSession")
|
||||
func gatePayloadRoutes() throws {
|
||||
let payload: [AnyHashable: Any] = [
|
||||
"sessionId": Self.validSessionId,
|
||||
"cls": "gate",
|
||||
"token": "opaque",
|
||||
"aps": ["category": "WEBTERM_GATE"],
|
||||
]
|
||||
|
||||
let sessionId = try #require(UUID(uuidString: Self.validSessionId))
|
||||
#expect(DeepLinkRouter.route(from: payload) == .gateSession(sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("payload 缺 sessionId / 非字符串 / 非 v4 → .ignore")
|
||||
func gatePayloadRejectsInvalid() {
|
||||
#expect(DeepLinkRouter.route(from: [:]) == .ignore)
|
||||
#expect(DeepLinkRouter.route(from: ["sessionId": 42]) == .ignore)
|
||||
#expect(DeepLinkRouter.route(from: ["sessionId": Self.v1StyleId]) == .ignore)
|
||||
}
|
||||
}
|
||||
|
||||
/// DeepLinkHandler:热路径直达、冷启动 stash、未知 host 落配对、忽略计数。
|
||||
@MainActor
|
||||
@Suite("DeepLinkHandler")
|
||||
struct DeepLinkHandlerTests {
|
||||
private enum StubError: Error { case storeFailure }
|
||||
|
||||
/// 记录 handler 触发的动作(测试替身;生产侧由 AppCoordinator 提供闭包)。
|
||||
@MainActor
|
||||
private final class ActionRecorder {
|
||||
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
|
||||
private(set) var pairingShownCount = 0
|
||||
|
||||
var actions: DeepLinkHandler.Actions {
|
||||
DeepLinkHandler.Actions(
|
||||
openSession: { [weak self] host, sessionId in
|
||||
self?.opened.append((host, sessionId))
|
||||
},
|
||||
showPairing: { [weak self] in
|
||||
self?.pairingShownCount += 1
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeHost(id: UUID = UUID()) throws -> HostRegistry.Host {
|
||||
let base = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: base))
|
||||
return HostRegistry.Host(id: id, name: "mac", endpoint: endpoint)
|
||||
}
|
||||
|
||||
private static func openURL(hostId: UUID, sessionId: UUID) throws -> URL {
|
||||
try #require(URL(
|
||||
string: "webterminal://open?host=\(hostId.uuidString)&join=\(sessionId.uuidString)"
|
||||
))
|
||||
}
|
||||
|
||||
/// UUID() 是 v4,但 uuidString 是大写——直接可作合法链接值。
|
||||
private static func sessionId() -> UUID { UUID() }
|
||||
|
||||
// MARK: - 热路径(app 已就绪)
|
||||
|
||||
@Test("热路径:已知 host → openSession(host, sessionId),无 hint")
|
||||
func warmKnownHostOpens() async throws {
|
||||
let host = try Self.makeHost()
|
||||
let sessionId = Self.sessionId()
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
|
||||
|
||||
#expect(recorder.opened.count == 1)
|
||||
#expect(recorder.opened.first?.host == host)
|
||||
#expect(recorder.opened.first?.sessionId == sessionId)
|
||||
#expect(recorder.pairingShownCount == 0)
|
||||
#expect(handler.hintMessage == nil)
|
||||
}
|
||||
|
||||
@Test("未知 host id(UUID 合法但不在 store)→ showPairing + 提示文案")
|
||||
func unknownHostRoutesToPairing() async throws {
|
||||
let stored = try Self.makeHost()
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [stored] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(
|
||||
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
|
||||
)
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 1)
|
||||
#expect(handler.hintMessage == DeepLinkCopy.unknownHostHint)
|
||||
}
|
||||
|
||||
@Test("host store 读失败 → 显式错误文案,不 showPairing、不 crash")
|
||||
func storeFailureSurfacesExplicitCopy() async throws {
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(
|
||||
loadHosts: { throw StubError.storeFailure },
|
||||
actions: recorder.actions
|
||||
)
|
||||
await handler.markReady()
|
||||
|
||||
await handler.handle(
|
||||
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
|
||||
)
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 0)
|
||||
#expect(handler.hintMessage == DeepLinkCopy.hostLoadFailed)
|
||||
}
|
||||
|
||||
// MARK: - 非法链接:计数 + 不入 stash
|
||||
|
||||
@Test("非法链接 → ignoredCount+1,无任何动作,也不入 stash")
|
||||
func invalidLinkCountedNeverStashed() async throws {
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(url: try #require(URL(string: "https://evil.example/?host=x")))
|
||||
#expect(handler.ignoredCount == 1)
|
||||
|
||||
await handler.markReady() // ready 后 stash 若被污染会在此触发动作
|
||||
|
||||
#expect(recorder.opened.isEmpty)
|
||||
#expect(recorder.pairingShownCount == 0)
|
||||
#expect(handler.hintMessage == nil)
|
||||
}
|
||||
|
||||
// MARK: - 冷启动 stash
|
||||
|
||||
@Test("冷启动:ready 前 handle → 挂起;markReady → 恰好应用一次")
|
||||
func coldLaunchStashAppliesOnReady() async throws {
|
||||
let host = try Self.makeHost()
|
||||
let sessionId = Self.sessionId()
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: sessionId))
|
||||
#expect(recorder.opened.isEmpty) // 未就绪:不得提前应用
|
||||
|
||||
await handler.markReady()
|
||||
#expect(recorder.opened.count == 1)
|
||||
#expect(recorder.opened.first?.sessionId == sessionId)
|
||||
|
||||
await handler.markReady() // 幂等:stash 已清空,不得重放
|
||||
#expect(recorder.opened.count == 1)
|
||||
}
|
||||
|
||||
@Test("ready 前连续两条链接 → 只应用最新一条(单槽 stash)")
|
||||
func stashKeepsOnlyLatestLink() async throws {
|
||||
let host = try Self.makeHost()
|
||||
let first = Self.sessionId()
|
||||
let second = Self.sessionId()
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [host] }, actions: recorder.actions)
|
||||
|
||||
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: first))
|
||||
await handler.handle(url: try Self.openURL(hostId: host.id, sessionId: second))
|
||||
await handler.markReady()
|
||||
|
||||
#expect(recorder.opened.count == 1)
|
||||
#expect(recorder.opened.first?.sessionId == second)
|
||||
}
|
||||
|
||||
// MARK: - hint 生命周期
|
||||
|
||||
@Test("clearHint → hintMessage 归位 nil")
|
||||
func clearHintResets() async throws {
|
||||
let recorder = ActionRecorder()
|
||||
let handler = DeepLinkHandler(loadHosts: { [] }, actions: recorder.actions)
|
||||
await handler.markReady()
|
||||
await handler.handle(
|
||||
url: try Self.openURL(hostId: UUID(), sessionId: Self.sessionId())
|
||||
)
|
||||
#expect(handler.hintMessage != nil)
|
||||
|
||||
handler.clearHint()
|
||||
|
||||
#expect(handler.hintMessage == nil)
|
||||
}
|
||||
}
|
||||
250
ios/App/WebTermTests/DiffFetcherTests.swift
Normal file
250
ios/App/WebTermTests/DiffFetcherTests.swift
Normal file
@@ -0,0 +1,250 @@
|
||||
import Foundation
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-27 · Diff 查看器 —— `DiffFetcher`(App 层,GET /projects/diff)。
|
||||
///
|
||||
/// 覆盖面(Steps 测试先行):
|
||||
/// - 请求形状:GET + path/staged 严格百分号编码 + **无 Origin**(RO 端点,
|
||||
/// §3.4 iff-G 铁律;服务器路由无 requireAllowedOrigin,src/server.ts:601-618);
|
||||
/// - `staged` 序列化为 `1`/`0` —— 服务器精确匹配 `=== '1'`(src/server.ts:613);
|
||||
/// - `DiffResult{files,staged,truncated}` 宽容解码(镜像 public/diff.ts
|
||||
/// normalizeDiffResult:顶层三键缺一 → 整体无效;畸形 file/hunk/line 逐条
|
||||
/// 丢弃;未知 kind → .context、未知 status → .modified——服务器是不可信输入源);
|
||||
/// - 400 → .pathInvalid、404 → .projectNotFound(SEC-H7 三叉校验失败)、
|
||||
/// 其余状态 → .unexpectedStatus;
|
||||
/// - 空 path 客户端先拒(镜像服务器 400 规则),零网络。
|
||||
struct DiffFetcherTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func makeEndpoint() throws -> HostEndpoint {
|
||||
let url = try #require(URL(string: "http://127.0.0.1:3000"))
|
||||
return try #require(HostEndpoint(baseURL: url))
|
||||
}
|
||||
|
||||
private static func makeFetcher() throws -> (DiffFetcher, FakeHTTPTransport) {
|
||||
let fake = FakeHTTPTransport()
|
||||
let fetcher = DiffFetcher(endpoint: try makeEndpoint(), http: fake)
|
||||
return (fetcher, fake)
|
||||
}
|
||||
|
||||
private static func url(_ s: String) throws -> URL {
|
||||
try #require(URL(string: s))
|
||||
}
|
||||
|
||||
/// 一份完整合法响应体(modified 文件 + 1 hunk + 4 行,覆盖 4 种 kind)。
|
||||
private static let fullBody = Data("""
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"oldPath": "src/a.ts", "newPath": "src/a.ts", "status": "modified",
|
||||
"added": 2, "removed": 1, "binary": false,
|
||||
"hunks": [
|
||||
{ "header": "@@ -1,3 +1,4 @@",
|
||||
"lines": [
|
||||
{ "kind": "context", "text": "unchanged" },
|
||||
{ "kind": "removed", "text": "old line" },
|
||||
{ "kind": "added", "text": "new line" },
|
||||
{ "kind": "meta", "text": "No newline at end of file" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
],
|
||||
"staged": false,
|
||||
"truncated": false
|
||||
}
|
||||
""".utf8)
|
||||
|
||||
// MARK: - 请求形状
|
||||
|
||||
@Test("GET /projects/diff?path=<enc>&staged=0,无 Origin(RO 端点,iff-G 铁律)")
|
||||
func requestShapeIsReadOnlyGetWithoutOrigin() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||||
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
|
||||
let recorded = await fake.recordedRequests
|
||||
let request = try #require(recorded.first)
|
||||
#expect(recorded.count == 1)
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.url == expected)
|
||||
// RO 一律不带 Origin:服务器若把该端点改为 G,这里先红(§3.4 铁律)。
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
|
||||
@Test("staged=true → staged=1(服务器 === '1' 精确匹配,src/server.ts:613)")
|
||||
func stagedSerializesAsOne() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||||
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: true)
|
||||
|
||||
let recorded = await fake.recordedRequests
|
||||
#expect(recorded.first?.url == expected)
|
||||
}
|
||||
|
||||
@Test("path 严格百分号编码:空格/&/+/= 全部转义(unreserved-only 集)")
|
||||
func pathIsStrictlyPercentEncoded() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
// " " → %20、"&" → %26、"+" → %2B、"=" → %3D、"/" → %2F
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Fa%20b%26c%2Bd%3De&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||||
|
||||
_ = try await fetcher.fetch(path: "/tmp/a b&c+d=e", staged: false)
|
||||
|
||||
let recorded = await fake.recordedRequests
|
||||
#expect(recorded.first?.url == expected)
|
||||
}
|
||||
|
||||
@Test("空 path → .invalidRequest,零网络(镜像服务器 400 规则,先于 I/O 拒绝)")
|
||||
func emptyPathRejectedBeforeNetwork() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
|
||||
await #expect(throws: DiffFetchError.invalidRequest) {
|
||||
_ = try await fetcher.fetch(path: "", staged: false)
|
||||
}
|
||||
|
||||
let recorded = await fake.recordedRequests
|
||||
#expect(recorded.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 解码(服务器为不可信输入源)
|
||||
|
||||
@Test("200 完整体 → DiffResult 各字段逐一到位")
|
||||
func decodesFullBody() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Self.fullBody)
|
||||
|
||||
let result = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
|
||||
#expect(result.staged == false)
|
||||
#expect(result.truncated == false)
|
||||
#expect(result.files.count == 1)
|
||||
let file = try #require(result.files.first)
|
||||
#expect(file.newPath == "src/a.ts")
|
||||
#expect(file.status == .modified)
|
||||
#expect(file.added == 2)
|
||||
#expect(file.removed == 1)
|
||||
#expect(file.binary == false)
|
||||
let hunk = try #require(file.hunks.first)
|
||||
#expect(hunk.header == "@@ -1,3 +1,4 @@")
|
||||
#expect(hunk.lines.map(\.kind) == [.context, .removed, .added, .meta])
|
||||
#expect(hunk.lines.map(\.text) == [
|
||||
"unchanged", "old line", "new line", "No newline at end of file",
|
||||
])
|
||||
}
|
||||
|
||||
@Test("宽容解码:畸形 file 条目丢弃、未知 kind → .context、未知 status → .modified")
|
||||
func tolerantDecodingDropsMalformedEntries() async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let body = Data("""
|
||||
{
|
||||
"files": [
|
||||
42,
|
||||
{ "oldPath": 1, "newPath": "x" },
|
||||
{
|
||||
"oldPath": "u.txt", "newPath": "u.txt", "status": "exotic-future",
|
||||
"added": 0, "removed": 0, "binary": false,
|
||||
"hunks": [
|
||||
{ "lines": [] },
|
||||
{ "header": "@@ -1 +1 @@",
|
||||
"lines": [
|
||||
{ "kind": "sparkle", "text": "mystery" },
|
||||
{ "kind": "added" },
|
||||
{ "kind": "added", "text": "kept" }
|
||||
] }
|
||||
]
|
||||
}
|
||||
],
|
||||
"staged": true,
|
||||
"truncated": true
|
||||
}
|
||||
""".utf8)
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=1"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: body)
|
||||
|
||||
let result = try await fetcher.fetch(path: "/tmp/repo", staged: true)
|
||||
|
||||
#expect(result.truncated == true)
|
||||
#expect(result.files.count == 1) // 42 与缺字段对象被丢弃
|
||||
let file = try #require(result.files.first)
|
||||
#expect(file.status == .modified) // 未知 status 降级
|
||||
#expect(file.hunks.count == 1) // 缺 header 的 hunk 被丢弃
|
||||
let lines = try #require(file.hunks.first).lines
|
||||
#expect(lines.count == 2) // 缺 text 的行被丢弃
|
||||
#expect(lines.first?.kind == .context) // 未知 kind 降级(同 web df-context 兜底)
|
||||
#expect(lines.last?.text == "kept")
|
||||
}
|
||||
|
||||
@Test("顶层畸形(缺键 / 非对象 / 非 JSON)→ .invalidResponse", arguments: [
|
||||
#"{"staged": false, "truncated": false}"#,
|
||||
#"[1, 2, 3]"#,
|
||||
"not json at all",
|
||||
])
|
||||
func malformedTopLevelIsInvalidResponse(raw: String) async throws {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(url: expected, body: Data(raw.utf8))
|
||||
|
||||
await #expect(throws: DiffFetchError.invalidResponse) {
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 错误状态映射(route 层语义,src/server.ts:602-618)
|
||||
|
||||
@Test("400 → .pathInvalid、404 → .projectNotFound、500 → .unexpectedStatus(500)")
|
||||
func statusCodesMapToTypedErrors() async throws {
|
||||
let cases: [(Int, DiffFetchError)] = [
|
||||
(400, .pathInvalid),
|
||||
(404, .projectNotFound),
|
||||
(500, .unexpectedStatus(500)),
|
||||
(403, .unexpectedStatus(403)),
|
||||
]
|
||||
for (status, expectedError) in cases {
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueSuccess(
|
||||
url: expected, status: status, body: Data(#"{"error":"x"}"#.utf8)
|
||||
)
|
||||
|
||||
await #expect(throws: expectedError) {
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("传输层错误原样上抛(不吞、不错误分类)")
|
||||
func transportErrorsPropagate() async throws {
|
||||
struct Boom: Error {}
|
||||
let (fetcher, fake) = try Self.makeFetcher()
|
||||
let expected = try Self.url(
|
||||
"http://127.0.0.1:3000/projects/diff?path=%2Ftmp%2Frepo&staged=0"
|
||||
)
|
||||
await fake.queueFailure(url: expected, error: Boom())
|
||||
|
||||
await #expect(throws: Boom.self) {
|
||||
_ = try await fetcher.fetch(path: "/tmp/repo", staged: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
242
ios/App/WebTermTests/DiffViewModelTests.swift
Normal file
242
ios/App/WebTermTests/DiffViewModelTests.swift
Normal file
@@ -0,0 +1,242 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-27 · Diff 查看器 —— `DiffViewModel`(phase 状态机 + 行平铺)。
|
||||
///
|
||||
/// 覆盖面(Steps 测试先行):
|
||||
/// - `DiffResult{files,staged,truncated}` → 呈现:文件头 → hunk 头 → 行,
|
||||
/// 平铺为惰性列表的行模型(巨 diff 不合成单个 Text 块);
|
||||
/// - truncated → banner 数据位(空态与非空态都要携带);
|
||||
/// - staged/unstaged 切换 → 以新 staged 重新 fetch;同值不重复请求;
|
||||
/// - path 非法(400/404)→ 显式友好错误 phase,其余错误 → 可重试的 unavailable;
|
||||
/// - binary 文件短路(镜像 web renderDiffFile 的 early return)、renamed 路径标签。
|
||||
@MainActor
|
||||
@Suite("DiffViewModel")
|
||||
struct DiffViewModelTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private nonisolated static func line(_ kind: DiffLineKind, _ text: String) -> DiffLine {
|
||||
DiffLine(kind: kind, text: text)
|
||||
}
|
||||
|
||||
private nonisolated static func modifiedFile() -> DiffFile {
|
||||
DiffFile(
|
||||
oldPath: "src/a.ts", newPath: "src/a.ts", status: .modified,
|
||||
added: 1, removed: 1, binary: false,
|
||||
hunks: [DiffHunk(header: "@@ -1,2 +1,2 @@", lines: [
|
||||
line(.removed, "old"),
|
||||
line(.added, "new"),
|
||||
])]
|
||||
)
|
||||
}
|
||||
|
||||
private nonisolated static func result(
|
||||
files: [DiffFile], staged: Bool = false, truncated: Bool = false
|
||||
) -> DiffResult {
|
||||
DiffResult(files: files, staged: staged, truncated: truncated)
|
||||
}
|
||||
|
||||
/// 记录每次 fetch 收到的 staged 参数(@Sendable 闭包内可变状态 → actor)。
|
||||
private actor FetchRecorder {
|
||||
private(set) var stagedArgs: [Bool] = []
|
||||
func record(_ staged: Bool) { stagedArgs = stagedArgs + [staged] }
|
||||
}
|
||||
|
||||
// MARK: - Phase 状态机
|
||||
|
||||
@Test("初始:phase = .loading、staged = false(默认工作区视图)")
|
||||
func initialState() {
|
||||
let vm = DiffViewModel(fetch: { _ in Self.result(files: []) })
|
||||
|
||||
#expect(vm.phase == .loading)
|
||||
#expect(vm.staged == false)
|
||||
}
|
||||
|
||||
@Test("load 成功(非空)→ .loaded,行序:文件头 → hunk 头 → 行,id 稳定递增")
|
||||
func loadFlattensRowsInOrder() async throws {
|
||||
let vm = DiffViewModel(fetch: { _ in Self.result(files: [Self.modifiedFile()]) })
|
||||
|
||||
await vm.load()
|
||||
|
||||
guard case .loaded(let presentation) = vm.phase else {
|
||||
Issue.record("期望 .loaded,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
#expect(presentation.truncated == false)
|
||||
#expect(presentation.rows.map(\.id) == [0, 1, 2, 3]) // Identifiable:惰性列表身份
|
||||
#expect(presentation.rows.map(\.kind) == [
|
||||
.fileHeader(DiffFileHeader(
|
||||
pathLabel: "src/a.ts", added: 1, removed: 1, status: .modified
|
||||
)),
|
||||
.hunkHeader("@@ -1,2 +1,2 @@"),
|
||||
.line(kind: .removed, text: "old"),
|
||||
.line(kind: .added, text: "new"),
|
||||
])
|
||||
}
|
||||
|
||||
@Test("空 files → .empty(truncated 数据位透传)", arguments: [false, true])
|
||||
func emptyFilesBecomeEmptyPhase(truncated: Bool) async {
|
||||
let vm = DiffViewModel(fetch: { _ in
|
||||
Self.result(files: [], truncated: truncated)
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .empty(truncated: truncated))
|
||||
}
|
||||
|
||||
@Test("truncated + 非空 → .loaded 且 presentation.truncated = true(banner 提示位)")
|
||||
func truncatedFlagSurvivesIntoPresentation() async throws {
|
||||
let vm = DiffViewModel(fetch: { _ in
|
||||
Self.result(files: [Self.modifiedFile()], truncated: true)
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
|
||||
guard case .loaded(let presentation) = vm.phase else {
|
||||
Issue.record("期望 .loaded,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
#expect(presentation.truncated == true)
|
||||
}
|
||||
|
||||
// MARK: - 行平铺规则(镜像 web renderDiffFile)
|
||||
|
||||
@Test("binary 文件:文件头 + 二进制占位,hunk 全部短路(web early return 同款)")
|
||||
func binaryFileShortCircuitsHunks() {
|
||||
let binary = DiffFile(
|
||||
oldPath: "logo.png", newPath: "logo.png", status: .binary,
|
||||
added: 0, removed: 0, binary: true,
|
||||
hunks: [DiffHunk(header: "@@ junk @@", lines: [Self.line(.added, "x")])]
|
||||
)
|
||||
|
||||
let rows = DiffViewModel.makeRows(files: [binary])
|
||||
|
||||
#expect(rows.map(\.kind) == [
|
||||
.fileHeader(DiffFileHeader(
|
||||
pathLabel: "logo.png", added: 0, removed: 0, status: .binary
|
||||
)),
|
||||
.binaryNotice,
|
||||
])
|
||||
}
|
||||
|
||||
@Test("renamed 且新旧路径不同 → 路径标签 “old → new”(web 同款箭头)")
|
||||
func renamedFileShowsArrowLabel() {
|
||||
let renamed = DiffFile(
|
||||
oldPath: "old/name.ts", newPath: "new/name.ts", status: .renamed,
|
||||
added: 0, removed: 0, binary: false, hunks: []
|
||||
)
|
||||
|
||||
let rows = DiffViewModel.makeRows(files: [renamed])
|
||||
|
||||
guard case .fileHeader(let header)? = rows.first?.kind else {
|
||||
Issue.record("期望 fileHeader,实际 \(String(describing: rows.first))")
|
||||
return
|
||||
}
|
||||
#expect(header.pathLabel == "old/name.ts → new/name.ts")
|
||||
}
|
||||
|
||||
@Test("untracked(0 hunk、非 binary)→ 仅文件头行")
|
||||
func untrackedFileIsHeaderOnly() {
|
||||
let untracked = DiffFile(
|
||||
oldPath: "notes.md", newPath: "notes.md", status: .untracked,
|
||||
added: 0, removed: 0, binary: false, hunks: []
|
||||
)
|
||||
|
||||
let rows = DiffViewModel.makeRows(files: [untracked])
|
||||
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows.first?.kind == .fileHeader(DiffFileHeader(
|
||||
pathLabel: "notes.md", added: 0, removed: 0, status: .untracked
|
||||
)))
|
||||
}
|
||||
|
||||
// MARK: - 错误映射(400/404 → 友好错误,任务 Steps)
|
||||
|
||||
@Test("DiffFetchError → Failure 映射:400/非法请求 → pathInvalid,404 → notFound,其余 → unavailable")
|
||||
func fetchErrorsMapToFailures() async {
|
||||
struct RandomError: Error {}
|
||||
let cases: [(any Error, DiffViewModel.Failure)] = [
|
||||
(DiffFetchError.pathInvalid, .pathInvalid),
|
||||
(DiffFetchError.invalidRequest, .pathInvalid),
|
||||
(DiffFetchError.projectNotFound, .notFound),
|
||||
(DiffFetchError.invalidResponse, .unavailable),
|
||||
(DiffFetchError.unexpectedStatus(500), .unavailable),
|
||||
(RandomError(), .unavailable),
|
||||
]
|
||||
for (thrown, expected) in cases {
|
||||
let vm = DiffViewModel(fetch: { _ in throw thrown })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .failed(expected))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("重试:失败后再次 load 成功 → .loaded(错误态可恢复)")
|
||||
func retryAfterFailureRecovers() async {
|
||||
struct Boom: Error {}
|
||||
let recorder = FetchRecorder()
|
||||
let vm = DiffViewModel(fetch: { staged in
|
||||
await recorder.record(staged)
|
||||
if await recorder.stagedArgs.count == 1 { throw Boom() }
|
||||
return Self.result(files: [Self.modifiedFile()])
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .failed(.unavailable))
|
||||
|
||||
await vm.load() // DiffScreen「重试」按钮走的就是这条路径
|
||||
|
||||
guard case .loaded = vm.phase else {
|
||||
Issue.record("期望 .loaded,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - staged/unstaged 切换(re-fetch 语义)
|
||||
|
||||
@Test("setStaged(true) → 以 staged=true 重新 fetch;同值再设 → 不重复请求")
|
||||
func setStagedRefetchesOnlyOnChange() async {
|
||||
let recorder = FetchRecorder()
|
||||
let vm = DiffViewModel(fetch: { staged in
|
||||
await recorder.record(staged)
|
||||
return Self.result(files: [], staged: staged)
|
||||
})
|
||||
await vm.load()
|
||||
|
||||
await vm.setStaged(true)
|
||||
await vm.setStaged(true) // 同值:必须是 no-op
|
||||
await vm.setStaged(false)
|
||||
|
||||
#expect(vm.staged == false)
|
||||
#expect(await recorder.stagedArgs == [false, true, false])
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量;空态 ≠ 错误态)
|
||||
|
||||
@Test("文案常量非空且空态/错误态互不相同")
|
||||
func copyConstantsAreDistinct() {
|
||||
#expect(!DiffCopy.title.isEmpty)
|
||||
#expect(!DiffCopy.truncatedBanner.isEmpty)
|
||||
#expect(!DiffCopy.emptyTitle.isEmpty)
|
||||
#expect(!DiffCopy.binaryFile.isEmpty)
|
||||
#expect(!DiffCopy.retry.isEmpty)
|
||||
#expect(DiffCopy.emptyTitle != DiffCopy.failedUnavailable)
|
||||
#expect(DiffCopy.failedPathInvalid != DiffCopy.failedNotFound)
|
||||
}
|
||||
|
||||
@Test("状态标签全函数映射(含全部六种 FileStatus,中文)")
|
||||
func statusLabelsAreTotalAndChinese() {
|
||||
let all: [DiffFileStatus] = [
|
||||
.modified, .added, .deleted, .renamed, .binary, .untracked,
|
||||
]
|
||||
for status in all {
|
||||
#expect(!DiffStatusStyle.label(for: status).isEmpty)
|
||||
}
|
||||
#expect(DiffStatusStyle.label(for: .added) == "新增")
|
||||
#expect(DiffStatusStyle.label(for: .deleted) == "删除")
|
||||
}
|
||||
}
|
||||
290
ios/App/WebTermTests/NewSessionInCwdTests.swift
Normal file
290
ios/App/WebTermTests/NewSessionInCwdTests.swift
Normal file
@@ -0,0 +1,290 @@
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-29 · 杂项闭环:new-in-cwd + 退出会话清理。
|
||||
///
|
||||
/// 1. "在当前目录开新会话"(TerminalScreen 工具栏 / exit 横幅共用动作)→
|
||||
/// `AppCoordinator.openNewSessionInCurrentCwd()`:单活 WS 不变式下的
|
||||
/// close→open,新会话首帧 `attach(null, cwd)`(镜像 web tabs.ts
|
||||
/// `newTab()` M6——取活跃会话 cwd)。cwd 解析次序:/live-sessions 行数据
|
||||
/// (server-adopted id 匹配行)→ controller 的 spawnCwd(T-iOS-26 项目
|
||||
/// fresh-spawn 尚未进列表轮询)→ 未知(普通新会话)。服务器数据不可信:
|
||||
/// 非绝对路径按未知处理(ProjectsViewModel 同款纪律)。
|
||||
/// 2. exited 会话点开 → 回放渲染 + exit 横幅(TerminalViewModel P0 已有)+
|
||||
/// 横幅"开新会话"动作(src/session/manager.ts:145-153:exited 会话在
|
||||
/// reap 前保持可列出,attach 即回放 ring buffer + 补发 exit 帧)。
|
||||
///
|
||||
/// 真 `SessionEngine`/`AppCoordinator` over FakeTransport/FakeHTTPTransport,
|
||||
/// 零真实等待(openTask + waitUntilProcessed 双屏障,ProjectOpenWiringTests
|
||||
/// 同款)。
|
||||
@MainActor
|
||||
@Suite("NewSessionInCwd (T-iOS-29)")
|
||||
struct NewSessionInCwdTests {
|
||||
private nonisolated static let base = "http://192.168.1.5:3000"
|
||||
|
||||
// MARK: - Fixtures(SessionSwitcherTests 的 WiringFixture 同款)
|
||||
|
||||
private struct Fixture {
|
||||
let transport: FakeTransport
|
||||
let http: FakeHTTPTransport
|
||||
let host: HostRegistry.Host
|
||||
let unreadStore: InMemoryUnreadWatermarkStore
|
||||
let environment: AppEnvironment
|
||||
}
|
||||
|
||||
private func makeFixture(suiteName: String) throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let transport = FakeTransport()
|
||||
let http = FakeHTTPTransport()
|
||||
let unreadStore = InMemoryUnreadWatermarkStore()
|
||||
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
return Fixture(
|
||||
transport: transport,
|
||||
http: http,
|
||||
host: host,
|
||||
unreadStore: unreadStore,
|
||||
environment: AppEnvironment(
|
||||
hostStore: InMemoryHostStore(hosts: [host]),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func listURL() throws -> URL {
|
||||
try #require(URL(string: "\(Self.base)/live-sessions"))
|
||||
}
|
||||
|
||||
private func sessionJSON(id: UUID, cwd: String?, exited: Bool = false) -> String {
|
||||
let cwdJSON = cwd.map { ",\"cwd\":\"\($0)\"" } ?? ""
|
||||
return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000"
|
||||
+ ",\"clientCount\":0,\"status\":\"idle\",\"exited\":\(exited)"
|
||||
+ "\(cwdJSON),\"cols\":80,\"rows\":24}"
|
||||
}
|
||||
|
||||
private func listBody(_ entries: [String]) -> Data {
|
||||
Data("[\(entries.joined(separator: ","))]".utf8)
|
||||
}
|
||||
|
||||
/// 让列表 VM 先掌握行数据(cwd 的唯一服务器来源)。
|
||||
private func primeRows(_ coordinator: AppCoordinator, fixture: Fixture, rows: [String]) async throws {
|
||||
await coordinator.sessionList.reloadHosts()
|
||||
await fixture.http.queueSuccess(url: try listURL(), body: listBody(rows))
|
||||
await coordinator.sessionList.refresh()
|
||||
}
|
||||
|
||||
/// attach 握手完成(服务器 adopted):openTask 完成 ∧ 前 3 个事件
|
||||
/// (connecting/connected/adopted)已应用。
|
||||
private func adopt(
|
||||
_ controller: TerminalSessionController,
|
||||
transport: FakeTransport,
|
||||
sessionId: UUID
|
||||
) async {
|
||||
await controller.openTask?.value
|
||||
await transport.emit(
|
||||
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
|
||||
)
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 3)
|
||||
}
|
||||
|
||||
/// 新 controller 的 open 已提交且连接事件已冲刷(帧必已落地)。
|
||||
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
|
||||
await controller.openTask?.value
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
|
||||
}
|
||||
|
||||
// MARK: - 1. 在当前会话 cwd 开新会话
|
||||
|
||||
@Test("cwd 来自 /live-sessions 行 → 切换后新连接首帧 attach(null, cwd);旧会话记 last-seen")
|
||||
func newInCwdUsesListRowCwd() async throws {
|
||||
// Arrange:列表已知该会话 cwd,打开并 adopted。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.rowCwd")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
let sessionId = UUID()
|
||||
try await primeRows(
|
||||
coordinator, fixture: fixture,
|
||||
rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj")]
|
||||
)
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: sessionId
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: sessionId)
|
||||
|
||||
// Act:工具栏/横幅动作。
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert:close→open —— 新 controller、新连接,首帧带行 cwd 的 fresh spawn。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
#expect(second !== first)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 2)
|
||||
#expect(byConnection[1] == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj"))
|
||||
])
|
||||
// 旧会话经 closeTerminal 记 last-seen(切走即已读)。
|
||||
#expect(fixture.unreadStore.snapshot[sessionId] != nil)
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("行缺席(fresh spawn 未入轮询)→ 回退 controller.spawnCwd;bootstrap 绝不复注入")
|
||||
func newInCwdFallsBackToSpawnCwdWithoutBootstrap() async throws {
|
||||
// Arrange:项目内 fresh spawn(spawnCwd + claude bootstrap),列表无行。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.spawnCwd")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
coordinator.openProject(ProjectOpenRequest(
|
||||
id: UUID(), host: fixture.host, cwd: "/repos/api",
|
||||
bootstrapInput: ProjectLaunch.claudeBootstrapInput
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: UUID())
|
||||
|
||||
// Act
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert:同 cwd,但只有裸 attach —— claude\r 不属于"开新 shell"。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 2)
|
||||
#expect(byConnection[1] == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api"))
|
||||
])
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("cwd 全未知(无行、无 spawnCwd)→ 普通新会话 attach(null, nil)")
|
||||
func newInCwdWithUnknownCwdOpensPlainSession() async throws {
|
||||
// Arrange:直接"+ 新会话"打开(无列表行),adopted id 不在任何行里。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.unknown")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: nil
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: UUID())
|
||||
|
||||
// Act
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert
|
||||
let second = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection.count == 2)
|
||||
#expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("服务器行 cwd 非绝对路径(不可信输入)→ 按未知处理,attach(null, nil)")
|
||||
func hostileRelativeCwdIsTreatedAsUnknown() async throws {
|
||||
// Arrange:敌意/损坏的服务器数据 —— cwd 不是绝对路径。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.hostile")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
let sessionId = UUID()
|
||||
try await primeRows(
|
||||
coordinator, fixture: fixture,
|
||||
rows: [sessionJSON(id: sessionId, cwd: "repos/../etc")]
|
||||
)
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: sessionId
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
await adopt(first, transport: fixture.transport, sessionId: sessionId)
|
||||
|
||||
// Act
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert:非法 cwd 绝不透传。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection[1] == [MessageCodec.encode(.attach(sessionId: nil, cwd: nil))])
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("无打开的终端 → 动作 no-op(不 spawn、不连接)")
|
||||
func actionWithoutOpenTerminalIsNoOp() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.noop")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
|
||||
// Act
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert
|
||||
#expect(coordinator.terminalController == nil)
|
||||
let attempts = await fixture.transport.connectAttempts
|
||||
#expect(attempts.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 2. exited 会话:回放 + exit 横幅 + 横幅"开新会话"
|
||||
|
||||
@Test("exited 会话点开:回放渲染 + exit 横幅只读 → 动作以该行 cwd 开新会话")
|
||||
func exitedSessionReplaysThenBannerActionReusesCwd() async throws {
|
||||
// Arrange:列表里的 exited 行(manager.ts:145-153——reap 前仍可列出)。
|
||||
let fixture = try makeFixture(suiteName: "NewSessionInCwdTests.exited")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
let sessionId = UUID()
|
||||
try await primeRows(
|
||||
coordinator, fixture: fixture,
|
||||
rows: [sessionJSON(id: sessionId, cwd: "/Users/dev/proj", exited: true)]
|
||||
)
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: sessionId
|
||||
))
|
||||
let first = try #require(coordinator.terminalController)
|
||||
var fed: [String] = []
|
||||
first.terminalViewModel.attachTerminalSink { fed.append($0) }
|
||||
await first.openTask?.value
|
||||
|
||||
// Act:服务器语义 —— 回放 ring buffer,随后补发 exit。
|
||||
await fixture.transport.emit(
|
||||
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
|
||||
)
|
||||
await fixture.transport.emit(frame: #"{"type":"output","data":"[replay] $ make done"}"#)
|
||||
await fixture.transport.emit(frame: #"{"type":"exit","code":0}"#)
|
||||
await first.terminalViewModel.waitUntilProcessed(eventCount: 5)
|
||||
|
||||
// Assert:回放已渲染、exit 横幅只读。
|
||||
#expect(fed == ["[replay] $ make done"])
|
||||
#expect(first.terminalViewModel.bannerModel == .exited(code: 0, reason: nil))
|
||||
#expect(first.terminalViewModel.isReadOnly)
|
||||
|
||||
// Act:横幅"开新会话"(同一 coordinator 动作)。
|
||||
coordinator.openNewSessionInCurrentCwd()
|
||||
|
||||
// Assert:同 cwd 的 fresh spawn。
|
||||
let second = try #require(coordinator.terminalController)
|
||||
await awaitAttachSettled(second)
|
||||
let byConnection = await fixture.transport.sentFramesByConnection
|
||||
#expect(byConnection[1] == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/Users/dev/proj"))
|
||||
])
|
||||
second.teardown()
|
||||
}
|
||||
|
||||
@Test("横幅'开新会话'只在 exited 态可用(failed/连接态不提供)")
|
||||
func bannerNewSessionAffordanceOnlyWhenExited() {
|
||||
#expect(ReconnectBanner.isNewSessionActionAvailable(for: .exited(code: 0, reason: nil)))
|
||||
#expect(ReconnectBanner.isNewSessionActionAvailable(
|
||||
for: .exited(code: -1, reason: "spawn failed")
|
||||
))
|
||||
#expect(!ReconnectBanner.isNewSessionActionAvailable(for: .connecting))
|
||||
#expect(!ReconnectBanner.isNewSessionActionAvailable(
|
||||
for: .reconnecting(attempt: 1, retryIn: .seconds(1))
|
||||
))
|
||||
#expect(!ReconnectBanner.isNewSessionActionAvailable(for: .failed(message: "x")))
|
||||
}
|
||||
}
|
||||
329
ios/App/WebTermTests/NotificationActionHandlerTests.swift
Normal file
329
ios/App/WebTermTests/NotificationActionHandlerTests.swift
Normal file
@@ -0,0 +1,329 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import TestSupport
|
||||
import UserNotifications
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-21 · NotificationActionHandler.handle:Allow/Deny → POST
|
||||
/// /hook/decision(背景任务包住、403 → 本地通知兜底)、默认点按 →
|
||||
/// DeepLinkRouter 同源路由到会话(parse 的 payload 级用例在
|
||||
/// NotificationActionParseTests.swift)。
|
||||
/// 说明:UNNotificationResponse 无法在单测构造 —— didReceive 薄胶水不在
|
||||
/// 单测覆盖内,全部逻辑经 parse(actionIdentifier:userInfo:) + handle(_:) 测。
|
||||
@MainActor
|
||||
@Suite("NotificationActionHandler")
|
||||
struct NotificationActionHandlerTests {
|
||||
// MARK: - Fakes
|
||||
|
||||
@MainActor
|
||||
private final class FakeBackgroundTasks: BackgroundTaskRunning {
|
||||
private(set) var begunNames: [String] = []
|
||||
private(set) var endedTokens: [Int] = []
|
||||
private var nextToken = 0
|
||||
|
||||
func begin(name: String) -> Int {
|
||||
begunNames.append(name)
|
||||
nextToken += 1
|
||||
return nextToken
|
||||
}
|
||||
|
||||
func end(_ token: Int) { endedTokens.append(token) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeNoticePoster: LocalNoticePosting {
|
||||
private(set) var posted: [(title: String, body: String)] = []
|
||||
func post(title: String, body: String) async { posted.append((title, body)) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OpenRecorder {
|
||||
private(set) var opened: [(host: HostRegistry.Host, sessionId: UUID)] = []
|
||||
var actions: NotificationActionHandler.Actions {
|
||||
NotificationActionHandler.Actions(openSession: { [weak self] host, sessionId in
|
||||
self?.opened.append((host, sessionId))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
/// 服务器 capability token = crypto.randomUUID()(src/server.ts:445)→ v4 形状。
|
||||
private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f"
|
||||
private static let hostABase = "http://192.168.1.5:3000"
|
||||
private static let hostBBase = "http://192.168.1.6:3000"
|
||||
|
||||
private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
private struct Harness {
|
||||
let handler: NotificationActionHandler
|
||||
let http: FakeHTTPTransport
|
||||
let backgroundTasks: FakeBackgroundTasks
|
||||
let notices: FakeNoticePoster
|
||||
let recorder: OpenRecorder
|
||||
}
|
||||
|
||||
private static func makeHarness(hosts: [HostRegistry.Host]) -> Harness {
|
||||
let http = FakeHTTPTransport()
|
||||
let backgroundTasks = FakeBackgroundTasks()
|
||||
let notices = FakeNoticePoster()
|
||||
let recorder = OpenRecorder()
|
||||
let handler = NotificationActionHandler(
|
||||
hostStore: InMemoryHostStore(hosts: hosts),
|
||||
http: http,
|
||||
backgroundTasks: backgroundTasks,
|
||||
notices: notices,
|
||||
actions: recorder.actions
|
||||
)
|
||||
return Harness(
|
||||
handler: handler, http: http, backgroundTasks: backgroundTasks,
|
||||
notices: notices, recorder: recorder
|
||||
)
|
||||
}
|
||||
|
||||
private static func decisionURL(base: String) throws -> URL {
|
||||
try #require(URL(string: "\(base)/hook/decision"))
|
||||
}
|
||||
|
||||
private static func liveSessionsURL(base: String) throws -> URL {
|
||||
try #require(URL(string: "\(base)/live-sessions"))
|
||||
}
|
||||
|
||||
/// 多主机场景的两条 RO 探查响应一次排好(A 无、B 持有目标会话)。
|
||||
private static func queueTwoHostProbe(_ harness: Harness) async throws {
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try liveSessionsURL(base: hostABase),
|
||||
body: liveSessionsBody(containing: nil)
|
||||
)
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try liveSessionsURL(base: hostBBase),
|
||||
body: liveSessionsBody(containing: sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
/// /live-sessions 响应体:可选包含目标会话(必填字段齐全的最小形状)。
|
||||
private static func liveSessionsBody(containing sessionId: String?) -> Data {
|
||||
guard let sessionId else { return Data("[]".utf8) }
|
||||
let json = """
|
||||
[{"id":"\(sessionId)","createdAt":1,"clientCount":0,"status":"waiting",
|
||||
"exited":false,"cols":80,"rows":24}]
|
||||
"""
|
||||
return Data(json.utf8)
|
||||
}
|
||||
|
||||
// MARK: - handle(.decision):POST /hook/decision
|
||||
|
||||
@Test("单主机 Allow → 直接 POST(免 RO 探查),体为 {sessionId,decision,token},带 Origin,背景任务成对")
|
||||
func decisionSingleHostPosts() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let requests = await harness.http.recordedRequests
|
||||
#expect(requests.count == 1)
|
||||
let request = try #require(requests.first)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.value(forHTTPHeaderField: "Origin") == Self.hostABase)
|
||||
let body = try JSONDecoder().decode(
|
||||
[String: String].self, from: try #require(request.httpBody)
|
||||
)
|
||||
#expect(body == [
|
||||
"sessionId": Self.sessionId, "decision": "allow", "token": Self.token,
|
||||
])
|
||||
#expect(harness.backgroundTasks.begunNames.count == 1)
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Deny → decision 字段为 deny")
|
||||
func decisionDenyPostsDeny() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.deny, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let request = try #require(await harness.http.recordedRequests.first)
|
||||
let body = try JSONDecoder().decode(
|
||||
[String: String].self, from: try #require(request.httpBody)
|
||||
)
|
||||
#expect(body["decision"] == "deny")
|
||||
}
|
||||
|
||||
@Test("403(token 过期/已用)→ 本地通知兜底提示进 App;文案不含 token;背景任务成对")
|
||||
func decisionRejectedPostsExpiredNotice() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase), status: 403
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
#expect(harness.notices.posted.count == 1)
|
||||
let notice = try #require(harness.notices.posted.first)
|
||||
#expect(notice.title == PushDecisionCopy.expiredTitle)
|
||||
#expect(notice.body == PushDecisionCopy.expiredBody)
|
||||
// capability token 用后即弃:绝不出现在任何兜底文案里。
|
||||
#expect(!notice.body.contains(Self.token) && !notice.title.contains(Self.token))
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
}
|
||||
|
||||
@Test("传输层失败 → 本地通知兜底(失败可见,绝不静默吞);背景任务成对")
|
||||
func decisionTransportFailurePostsFailedNotice() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
await harness.http.queueFailure(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostABase),
|
||||
error: URLError(.cannotConnectToHost)
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
#expect(harness.notices.posted.count == 1)
|
||||
#expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle)
|
||||
#expect(harness.backgroundTasks.begunNames.count == 1)
|
||||
#expect(harness.backgroundTasks.endedTokens.count == 1)
|
||||
}
|
||||
|
||||
@Test("多主机 → 先 RO 查 /live-sessions(无 Origin)定位持有会话的主机,再对其 POST")
|
||||
func decisionMultiHostResolvesOwner() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
try await Self.queueTwoHostProbe(harness)
|
||||
await harness.http.queueSuccess(
|
||||
method: "POST", url: try Self.decisionURL(base: Self.hostBBase), status: 204
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let requests = await harness.http.recordedRequests
|
||||
let gets = requests.filter { $0.httpMethod == "GET" }
|
||||
#expect(gets.count == 2)
|
||||
for get in gets { // RO 铁律:探查一律不带 Origin
|
||||
#expect(get.value(forHTTPHeaderField: "Origin") == nil)
|
||||
}
|
||||
let posts = requests.filter { $0.httpMethod == "POST" }
|
||||
#expect(posts.count == 1)
|
||||
#expect(posts.first?.url == (try Self.decisionURL(base: Self.hostBBase)))
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
}
|
||||
|
||||
@Test("多主机但会话无处可寻 → 不 POST,本地通知兜底")
|
||||
func decisionUnresolvableHostFailsVisibly() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try Self.liveSessionsURL(base: Self.hostABase),
|
||||
body: Self.liveSessionsBody(containing: nil)
|
||||
)
|
||||
await harness.http.queueSuccess(
|
||||
method: "GET", url: try Self.liveSessionsURL(base: Self.hostBBase),
|
||||
body: Self.liveSessionsBody(containing: nil)
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(
|
||||
.decision(.allow, sessionId: sessionId, token: Self.token)
|
||||
)
|
||||
|
||||
let posts = await harness.http.recordedRequests.filter { $0.httpMethod == "POST" }
|
||||
#expect(posts.isEmpty)
|
||||
#expect(harness.notices.posted.first?.title == PushDecisionCopy.failedTitle)
|
||||
}
|
||||
|
||||
// MARK: - handle(.openSession):默认点按路由
|
||||
|
||||
@Test("单主机点按 → openSession(host, id),不发决策 POST,也不开背景任务")
|
||||
func openSessionSingleHostRoutes() async throws {
|
||||
let host = try Self.makeHost(base: Self.hostABase)
|
||||
let harness = Self.makeHarness(hosts: [host])
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.count == 1)
|
||||
#expect(harness.recorder.opened.first?.host == host)
|
||||
#expect(harness.recorder.opened.first?.sessionId == sessionId)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
#expect(harness.backgroundTasks.begunNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("多主机点按 → RO 定位后打开持有主机")
|
||||
func openSessionMultiHostResolves() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let harness = Self.makeHarness(hosts: [hostA, hostB])
|
||||
try await Self.queueTwoHostProbe(harness)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.first?.host == hostB)
|
||||
}
|
||||
|
||||
@Test("无主机可配 → 不打开、不 crash(App 已被点按拉起,落在列表页即可)")
|
||||
func openSessionUnresolvedIsSafeNoOp() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
|
||||
await harness.handler.handle(.openSession(sessionId: sessionId))
|
||||
|
||||
#expect(harness.recorder.opened.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 其余分支
|
||||
|
||||
@Test(".invalidPayload → 计数 +1,无网络、无通知、无路由")
|
||||
func invalidPayloadCountedOnly() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
|
||||
await harness.handler.handle(.invalidPayload)
|
||||
|
||||
#expect(harness.handler.invalidPayloadCount == 1)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
#expect(harness.notices.posted.isEmpty)
|
||||
#expect(harness.recorder.opened.isEmpty)
|
||||
}
|
||||
|
||||
@Test(".dismissed → 完全 no-op")
|
||||
func dismissedIsNoOp() async throws {
|
||||
let harness = Self.makeHarness(hosts: [])
|
||||
|
||||
await harness.handler.handle(.dismissed)
|
||||
|
||||
#expect(harness.handler.invalidPayloadCount == 0)
|
||||
#expect(await harness.http.recordedRequests.isEmpty)
|
||||
}
|
||||
}
|
||||
104
ios/App/WebTermTests/NotificationActionParseTests.swift
Normal file
104
ios/App/WebTermTests/NotificationActionParseTests.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
import UserNotifications
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-21 · `NotificationActionHandler.parse`:payload 级白名单解析
|
||||
///(push payload 是不可信外部输入——sessionId 复用 DeepLinkRouter 的冻结
|
||||
/// v4 校验,token 校验 v4 形状后原样透传,任一非法 → .invalidPayload)。
|
||||
@MainActor
|
||||
@Suite("NotificationActionHandler.parse")
|
||||
struct NotificationActionParseTests {
|
||||
private static let sessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
|
||||
/// 服务器 capability token = crypto.randomUUID()(src/server.ts:445)→ v4 形状。
|
||||
private static let token = "7c1de1e0-9a2b-4c3d-8e4f-5a6b7c8d9e0f"
|
||||
|
||||
private static func gateUserInfo(
|
||||
sessionId: String = sessionId, token: Any? = token
|
||||
) -> [AnyHashable: Any] {
|
||||
var info: [AnyHashable: Any] = [
|
||||
"aps": ["category": "WEBTERM_GATE"],
|
||||
"sessionId": sessionId,
|
||||
"cls": "needs-input",
|
||||
]
|
||||
if let token { info["token"] = token }
|
||||
return info
|
||||
}
|
||||
|
||||
@Test("Allow 动作 + 合法 payload → .decision(.allow)")
|
||||
func parseAllowAction() throws {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||||
userInfo: Self.gateUserInfo()
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
#expect(parsed == .decision(.allow, sessionId: sessionId, token: Self.token))
|
||||
}
|
||||
|
||||
@Test("Deny 动作 + 合法 payload → .decision(.deny)")
|
||||
func parseDenyAction() throws {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.denyActionId,
|
||||
userInfo: Self.gateUserInfo()
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
#expect(parsed == .decision(.deny, sessionId: sessionId, token: Self.token))
|
||||
}
|
||||
|
||||
@Test("决策动作缺 token / token 非字符串 / token 非 v4 形状 → .invalidPayload")
|
||||
func parseDecisionRejectsBadToken() {
|
||||
for bad in [nil, 42 as Any, "not-a-token", "11111111-2222-1333-8444-555555555555"] {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||||
userInfo: Self.gateUserInfo(token: bad)
|
||||
)
|
||||
#expect(parsed == .invalidPayload)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("决策动作 sessionId 非法(非 v4 / 缺失)→ .invalidPayload")
|
||||
func parseDecisionRejectsBadSessionId() {
|
||||
let badId = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.allowActionId,
|
||||
userInfo: Self.gateUserInfo(sessionId: "garbage")
|
||||
)
|
||||
#expect(badId == .invalidPayload)
|
||||
let missing = NotificationActionHandler.parse(
|
||||
actionIdentifier: GateNotificationCategory.denyActionId,
|
||||
userInfo: ["token": Self.token]
|
||||
)
|
||||
#expect(missing == .invalidPayload)
|
||||
}
|
||||
|
||||
@Test("默认点按 + 合法 sessionId → .openSession(复用 DeepLinkRouter 同一解析)")
|
||||
func parseDefaultTapRoutes() throws {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||||
userInfo: Self.gateUserInfo(token: nil) // done 类通知无 token,点按仍可路由
|
||||
)
|
||||
let sessionId = try #require(UUID(uuidString: Self.sessionId))
|
||||
#expect(parsed == .openSession(sessionId: sessionId))
|
||||
}
|
||||
|
||||
@Test("默认点按 + 非法 payload → .invalidPayload")
|
||||
func parseDefaultTapRejectsGarbage() {
|
||||
let parsed = NotificationActionHandler.parse(
|
||||
actionIdentifier: UNNotificationDefaultActionIdentifier,
|
||||
userInfo: ["sessionId": "'; DROP TABLE sessions;--"]
|
||||
)
|
||||
#expect(parsed == .invalidPayload)
|
||||
}
|
||||
|
||||
@Test("dismiss / 未知动作 id → .dismissed(no-op)")
|
||||
func parseDismissAndUnknownActions() {
|
||||
#expect(NotificationActionHandler.parse(
|
||||
actionIdentifier: UNNotificationDismissActionIdentifier,
|
||||
userInfo: Self.gateUserInfo()
|
||||
) == .dismissed)
|
||||
#expect(NotificationActionHandler.parse(
|
||||
actionIdentifier: "EVIL_ACTION", userInfo: Self.gateUserInfo()
|
||||
) == .dismissed)
|
||||
}
|
||||
}
|
||||
118
ios/App/WebTermTests/ProjectDetailViewModelTests.swift
Normal file
118
ios/App/WebTermTests/ProjectDetailViewModelTests.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-26 · ProjectDetailViewModel(详情 phase 状态机 + 400/404/500 显式
|
||||
/// 错误路径,镜像 DiffViewModel 的四结局纪律)。
|
||||
///
|
||||
/// fetch 闭包注入(生产由 `forHost` 包 `APIClient.projectDetail(path:)`,
|
||||
/// 其 builder/解码/状态码映射已在 APIClient 包内测过 —— 此处只测 VM 归约)。
|
||||
@MainActor
|
||||
@Suite("ProjectDetailViewModel")
|
||||
struct ProjectDetailViewModelTests {
|
||||
private nonisolated static func detail(
|
||||
sessions: [ProjectSessionRef] = [],
|
||||
worktrees: [WorktreeInfo] = [],
|
||||
hasClaudeMd: Bool = false
|
||||
) -> ProjectDetail {
|
||||
ProjectDetail(
|
||||
name: "web-terminal", path: "/repos/web-terminal", isGit: true,
|
||||
branch: "main", dirty: true, worktrees: worktrees,
|
||||
sessions: sessions, hasClaudeMd: hasClaudeMd, claudeMd: nil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("初始 .loading;load 成功 → .loaded(sessions/worktrees/hasClaudeMd 透传)")
|
||||
func loadSuccess() async throws {
|
||||
let payload = Self.detail(
|
||||
sessions: [ProjectSessionRef(
|
||||
id: UUID(), title: "web-terminal", status: .working,
|
||||
clientCount: 2, createdAt: 1, exited: false
|
||||
)],
|
||||
worktrees: [WorktreeInfo(
|
||||
path: "/repos/wt", branch: "feat/x", head: "abc",
|
||||
isMain: false, isCurrent: true, locked: nil, prunable: nil
|
||||
)],
|
||||
hasClaudeMd: true
|
||||
)
|
||||
let vm = ProjectDetailViewModel(path: payload.path, fetch: { payload })
|
||||
#expect(vm.phase == .loading)
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .loaded(payload))
|
||||
}
|
||||
|
||||
@Test(
|
||||
"错误映射:400→pathInvalid、404→notFound、500/解码/传输→unavailable",
|
||||
arguments: [
|
||||
(APIClientError.projectPathInvalid, ProjectDetailViewModel.Failure.pathInvalid),
|
||||
(APIClientError.projectNotFound, .notFound),
|
||||
(APIClientError.projectDetailUnavailable, .unavailable),
|
||||
(APIClientError.invalidResponseBody, .unavailable),
|
||||
]
|
||||
)
|
||||
func errorMapping(
|
||||
error: APIClientError, expected: ProjectDetailViewModel.Failure
|
||||
) async {
|
||||
let vm = ProjectDetailViewModel(path: "/p", fetch: { throw error })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .failed(expected))
|
||||
}
|
||||
|
||||
@Test("传输层任意错误 → .unavailable(可重试兜底,绝不 crash)")
|
||||
func transportErrorFallsBackToUnavailable() async {
|
||||
let vm = ProjectDetailViewModel(
|
||||
path: "/p", fetch: { throw URLError(.notConnectedToInternet) }
|
||||
)
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .failed(.unavailable))
|
||||
}
|
||||
|
||||
@Test("重试路径:failed 后再次 load 成功 → loaded")
|
||||
func retryAfterFailure() async {
|
||||
let flag = FailOnceFlag()
|
||||
let payload = Self.detail()
|
||||
let vm = ProjectDetailViewModel(path: payload.path, fetch: {
|
||||
if await flag.consumeShouldFail() { throw APIClientError.projectDetailUnavailable }
|
||||
return payload
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .failed(.unavailable))
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .loaded(payload))
|
||||
}
|
||||
|
||||
@Test("failureCopy:三种失败各有非空、互不相同的中文文案")
|
||||
func failureCopyIsDistinct() {
|
||||
let copies = [
|
||||
ProjectDetailScreen.failureCopy(.pathInvalid),
|
||||
ProjectDetailScreen.failureCopy(.notFound),
|
||||
ProjectDetailScreen.failureCopy(.unavailable),
|
||||
]
|
||||
|
||||
for copy in copies {
|
||||
#expect(!copy.title.isEmpty)
|
||||
#expect(!copy.detail.isEmpty)
|
||||
}
|
||||
#expect(Set(copies.map(\.title)).count == copies.count)
|
||||
}
|
||||
}
|
||||
|
||||
/// @Sendable fetch 闭包里的可变一次性失败开关(actor 隔离)。
|
||||
private actor FailOnceFlag {
|
||||
private var shouldFail = true
|
||||
|
||||
func consumeShouldFail() -> Bool {
|
||||
defer { shouldFail = false }
|
||||
return shouldFail
|
||||
}
|
||||
}
|
||||
211
ios/App/WebTermTests/ProjectGroupingTests.swift
Normal file
211
ios/App/WebTermTests/ProjectGroupingTests.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-26 · Projects 列表的纯分组逻辑 —— 逐条镜像 web v0.6 的
|
||||
/// public/projects.ts 规则(filterProjects / sortProjects / groupProjects /
|
||||
/// displayLabel):
|
||||
/// - namespace = 名字的前两个点分段(不足两段 → 无 namespace);
|
||||
/// - 成员 < MIN_GROUP_SIZE(2) 的 namespace 塌进 Other;
|
||||
/// - 有运行中会话的项目**复制**进置顶的 "Active now" 组(sessions[].exited
|
||||
/// 字段实测存在 —— active 置顶按任务要求 assert reality 后实现);
|
||||
/// - 一个 namespace 组都没有 → 单一 flat 组(无 chrome 的平铺网格回退);
|
||||
/// - 组 key 与 web 逐字节一致(" active" / " other" / namespace 原始大小写),
|
||||
/// 因为 collapsed 状态经 /prefs 跨端共享,key 不一致就互相丢状态。
|
||||
struct ProjectGroupingTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func session(exited: Bool) -> ProjectSessionRef {
|
||||
ProjectSessionRef(
|
||||
id: UUID(), title: nil, status: .working,
|
||||
clientCount: 1, createdAt: 0, exited: exited
|
||||
)
|
||||
}
|
||||
|
||||
private static func project(
|
||||
_ name: String,
|
||||
path: String? = nil,
|
||||
lastActiveMs: Int? = nil,
|
||||
running: Bool = false,
|
||||
exitedSession: Bool = false
|
||||
) -> ProjectInfo {
|
||||
var sessions: [ProjectSessionRef] = []
|
||||
if running { sessions.append(session(exited: false)) }
|
||||
if exitedSession { sessions.append(session(exited: true)) }
|
||||
return ProjectInfo(
|
||||
name: name, path: path ?? "/repos/\(name)", isGit: true,
|
||||
branch: "main", dirty: nil, lastActiveMs: lastActiveMs,
|
||||
sessions: sessions
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - filter(大小写不敏感的 name/path 子串,镜像 filterProjects)
|
||||
|
||||
@Test("filter:空查询原样返回全部(含前后空白仅剩空白的查询)")
|
||||
func filterEmptyQueryReturnsAll() {
|
||||
let projects = [Self.project("a"), Self.project("b")]
|
||||
|
||||
#expect(ProjectGrouping.filter(projects, query: "") == projects)
|
||||
#expect(ProjectGrouping.filter(projects, query: " ") == projects)
|
||||
}
|
||||
|
||||
@Test("filter:name 与 path 子串均命中,大小写不敏感")
|
||||
func filterMatchesNameAndPathCaseInsensitively() {
|
||||
let byName = Self.project("Billo.Platform.api", path: "/x/one")
|
||||
let byPath = Self.project("zzz", path: "/Users/dev/BILLO-extras")
|
||||
let miss = Self.project("other", path: "/y/two")
|
||||
|
||||
let result = ProjectGrouping.filter([byName, byPath, miss], query: "billo")
|
||||
|
||||
#expect(result == [byName, byPath])
|
||||
}
|
||||
|
||||
// MARK: - sort(favourites 优先 → lastActiveMs 降序 → 输入序稳定)
|
||||
|
||||
@Test("sort:收藏优先,其余按 lastActiveMs 降序,缺失视为 0")
|
||||
func sortFavouritesFirstThenRecency() {
|
||||
let fav = Self.project("fav", lastActiveMs: 1)
|
||||
let newer = Self.project("newer", lastActiveMs: 100)
|
||||
let older = Self.project("older", lastActiveMs: 50)
|
||||
let never = Self.project("never", lastActiveMs: nil)
|
||||
|
||||
let result = ProjectGrouping.sort(
|
||||
[never, older, fav, newer], favourites: [fav.path]
|
||||
)
|
||||
|
||||
#expect(result == [fav, newer, older, never])
|
||||
}
|
||||
|
||||
@Test("sort:全键相等时保持输入顺序(稳定排序,镜像 JS stable sort)")
|
||||
func sortIsStableOnTies() {
|
||||
let a = Self.project("a", lastActiveMs: 5)
|
||||
let b = Self.project("b", lastActiveMs: 5)
|
||||
let c = Self.project("c", lastActiveMs: 5)
|
||||
|
||||
#expect(ProjectGrouping.sort([a, b, c], favourites: []) == [a, b, c])
|
||||
}
|
||||
|
||||
// MARK: - namespace 分组
|
||||
|
||||
@Test("group:≥2 成员的 namespace 成组,单成员与无点名塌进 Other")
|
||||
func groupNamespacesAndOther() {
|
||||
let apiProj = Self.project("Billo.Platform.api", lastActiveMs: 2)
|
||||
let webProj = Self.project("Billo.Platform.web", lastActiveMs: 1)
|
||||
let solo = Self.project("solo.thing")
|
||||
let plain = Self.project("plain")
|
||||
|
||||
let groups = ProjectGrouping.group(
|
||||
[apiProj, webProj, solo, plain], favourites: []
|
||||
)
|
||||
|
||||
#expect(groups.count == 2)
|
||||
#expect(groups[0].kind == .namespace)
|
||||
#expect(groups[0].key == "Billo.Platform")
|
||||
#expect(groups[0].label == "Billo.Platform")
|
||||
#expect(groups[0].projects == [apiProj, webProj])
|
||||
#expect(groups[1].kind == .other)
|
||||
#expect(groups[1].key == ProjectGrouping.otherGroupKey)
|
||||
// 镜像 web groupProjects:other 先收无点名(noNamespace),再追加
|
||||
// 塌掉的单成员 namespace(public/projects.ts:163-166)→ plain 在前。
|
||||
#expect(groups[1].projects == [plain, solo])
|
||||
}
|
||||
|
||||
@Test("group:namespace 桶大小写不敏感合并,显示名取首见大小写")
|
||||
func groupBucketsCaseInsensitively() {
|
||||
let first = Self.project("Billo.Platform.a")
|
||||
let second = Self.project("billo.platform.b")
|
||||
|
||||
let groups = ProjectGrouping.group([first, second], favourites: [])
|
||||
|
||||
#expect(groups.count == 1)
|
||||
#expect(groups[0].key == "Billo.Platform")
|
||||
#expect(groups[0].projects == [first, second])
|
||||
}
|
||||
|
||||
@Test("group:没有任何 namespace 组 → 单一 flat 组(全部项目,无 chrome 回退)")
|
||||
func groupFlatFallback() {
|
||||
let solo = Self.project("solo.thing", lastActiveMs: 1)
|
||||
let plain = Self.project("plain", lastActiveMs: 2)
|
||||
|
||||
let groups = ProjectGrouping.group([solo, plain], favourites: [])
|
||||
|
||||
#expect(groups.count == 1)
|
||||
#expect(groups[0].kind == .flat)
|
||||
#expect(groups[0].key == ProjectGrouping.otherGroupKey)
|
||||
#expect(groups[0].projects == [plain, solo]) // recency 降序
|
||||
#expect(groups[0].isCollapsible == false)
|
||||
}
|
||||
|
||||
@Test("group:运行中的项目复制进置顶 Active 组;activeCount 逐组统计")
|
||||
func groupActivePinning() {
|
||||
let runningA = Self.project("Billo.Platform.api", lastActiveMs: 2, running: true)
|
||||
let idleB = Self.project("Billo.Platform.web", lastActiveMs: 1)
|
||||
let exitedOnly = Self.project("Billo.Platform.cli", exitedSession: true)
|
||||
|
||||
let groups = ProjectGrouping.group(
|
||||
[runningA, idleB, exitedOnly], favourites: []
|
||||
)
|
||||
|
||||
#expect(groups.count == 2)
|
||||
#expect(groups[0].kind == .active)
|
||||
#expect(groups[0].key == ProjectGrouping.activeGroupKey)
|
||||
#expect(groups[0].projects == [runningA]) // exited 会话不算 running
|
||||
#expect(groups[0].activeCount == 1)
|
||||
#expect(groups[1].kind == .namespace)
|
||||
#expect(groups[1].projects.contains(runningA)) // 复制而非移动
|
||||
#expect(groups[1].activeCount == 1)
|
||||
#expect(groups[0].isCollapsible == false) // Active now 永远展开
|
||||
#expect(groups[1].isCollapsible == true)
|
||||
}
|
||||
|
||||
@Test("group:namespace 组按组内最新 lastActiveMs 降序,平局按 label 升序")
|
||||
func groupOrdersNamespacesByRecencyThenLabel() {
|
||||
let older1 = Self.project("Aaa.Team.x", lastActiveMs: 10)
|
||||
let older2 = Self.project("Aaa.Team.y", lastActiveMs: 20)
|
||||
let newer1 = Self.project("Zzz.Team.x", lastActiveMs: 5)
|
||||
let newer2 = Self.project("Zzz.Team.y", lastActiveMs: 99)
|
||||
let tieA1 = Self.project("Bbb.Tie.x", lastActiveMs: 20)
|
||||
let tieA2 = Self.project("Bbb.Tie.y", lastActiveMs: 3)
|
||||
|
||||
let groups = ProjectGrouping.group(
|
||||
[older1, older2, newer1, newer2, tieA1, tieA2], favourites: []
|
||||
)
|
||||
|
||||
#expect(groups.map(\.key) == ["Zzz.Team", "Aaa.Team", "Bbb.Tie"])
|
||||
}
|
||||
|
||||
// MARK: - displayLabel(组内卡片名去掉 namespace 前缀)
|
||||
|
||||
@Test("displayLabel:namespace 组内剥前缀(大小写不敏感);哨兵组保留全名")
|
||||
func displayLabelStripsNamespacePrefix() {
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "Billo.Platform.api", groupKey: "Billo.Platform"
|
||||
) == "api")
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "billo.platform.api", groupKey: "Billo.Platform"
|
||||
) == "api")
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "unrelated", groupKey: "Billo.Platform"
|
||||
) == "unrelated")
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "Billo.Platform.api", groupKey: ProjectGrouping.activeGroupKey
|
||||
) == "Billo.Platform.api")
|
||||
#expect(ProjectGrouping.displayLabel(
|
||||
name: "Billo.Platform.api", groupKey: ProjectGrouping.otherGroupKey
|
||||
) == "Billo.Platform.api")
|
||||
}
|
||||
|
||||
@Test("group:组内排序收藏优先(favourites 参与 sortProjects)")
|
||||
func groupSortsFavouritesFirstWithinGroup() {
|
||||
let apiProj = Self.project("Ns.Grp.api", lastActiveMs: 9)
|
||||
let webProj = Self.project("Ns.Grp.web", lastActiveMs: 1)
|
||||
|
||||
let groups = ProjectGrouping.group(
|
||||
[apiProj, webProj], favourites: [webProj.path]
|
||||
)
|
||||
|
||||
#expect(groups.count == 1)
|
||||
#expect(groups[0].projects == [webProj, apiProj])
|
||||
}
|
||||
}
|
||||
90
ios/App/WebTermTests/ProjectOpenWiringTests.swift
Normal file
90
ios/App/WebTermTests/ProjectOpenWiringTests.swift
Normal file
@@ -0,0 +1,90 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-26 · "在此仓库开新会话" 的接线证明:`TerminalSessionController` 以
|
||||
/// `spawnCwd` + `bootstrapInput` 启动时,线上帧序 = `attach(null, cwd)` →
|
||||
/// `input("claude\r")`(engine 的 attach-first 队列语义保证 bootstrap 绝不
|
||||
/// 先于 attach 出线);带 sessionId 的常规打开则既无 cwd 也无 bootstrap。
|
||||
///
|
||||
/// 真 `SessionEngine` over `FakeTransport`(零真实等待):帧断言经
|
||||
/// `openTask` + `waitUntilProcessed` 双屏障(open+send 已提交 ∧ attach
|
||||
/// 握手已完成 → 帧必已落地)。
|
||||
@MainActor
|
||||
@Suite("ProjectOpenWiring")
|
||||
struct ProjectOpenWiringTests {
|
||||
private struct Fixture {
|
||||
let transport: FakeTransport
|
||||
let host: HostRegistry.Host
|
||||
let environment: AppEnvironment
|
||||
}
|
||||
|
||||
private func makeFixture() throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let transport = FakeTransport()
|
||||
let defaults = try #require(UserDefaults(suiteName: "ProjectOpenWiringTests"))
|
||||
return Fixture(
|
||||
transport: transport,
|
||||
host: HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint),
|
||||
environment: AppEnvironment(
|
||||
hostStore: InMemoryHostStore(),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: FakeHTTPTransport(),
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 双屏障:controller 的 open+bootstrap Task 完成(send 已提交/入队)∧
|
||||
/// attach 握手完成(.connecting/.connected 已到 VM → 队列已冲刷)。
|
||||
private func awaitAttachSettled(_ controller: TerminalSessionController) async {
|
||||
await controller.openTask?.value
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 2)
|
||||
}
|
||||
|
||||
@Test("spawn 变体:帧序 = attach(null, cwd) → input(claude\\r)")
|
||||
func spawnSendsAttachWithCwdThenBootstrapInput() async throws {
|
||||
let fixture = try makeFixture()
|
||||
let controller = TerminalSessionController(
|
||||
host: fixture.host, sessionId: nil, environment: fixture.environment,
|
||||
onPendingChanged: { _, _ in },
|
||||
spawnCwd: "/repos/api",
|
||||
bootstrapInput: ProjectLaunch.claudeBootstrapInput
|
||||
)
|
||||
|
||||
controller.start()
|
||||
await awaitAttachSettled(controller)
|
||||
|
||||
let frames = await fixture.transport.sentFrames
|
||||
#expect(frames == [
|
||||
MessageCodec.encode(.attach(sessionId: nil, cwd: "/repos/api")),
|
||||
MessageCodec.encode(.input(data: ProjectLaunch.claudeBootstrapInput)),
|
||||
])
|
||||
controller.teardown()
|
||||
}
|
||||
|
||||
@Test("常规打开(带 sessionId):只有 attach,无 cwd、无 bootstrap")
|
||||
func plainOpenSendsBareAttach() async throws {
|
||||
let fixture = try makeFixture()
|
||||
let sessionId = UUID()
|
||||
let controller = TerminalSessionController(
|
||||
host: fixture.host, sessionId: sessionId,
|
||||
environment: fixture.environment,
|
||||
onPendingChanged: { _, _ in }
|
||||
)
|
||||
|
||||
controller.start()
|
||||
await awaitAttachSettled(controller)
|
||||
|
||||
let frames = await fixture.transport.sentFrames
|
||||
#expect(frames == [MessageCodec.encode(.attach(sessionId: sessionId, cwd: nil))])
|
||||
controller.teardown()
|
||||
}
|
||||
}
|
||||
289
ios/App/WebTermTests/ProjectsViewModelTests.swift
Normal file
289
ios/App/WebTermTests/ProjectsViewModelTests.swift
Normal file
@@ -0,0 +1,289 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-26 · ProjectsViewModel(列表 + favourites/collapse 的 /prefs 往返)。
|
||||
///
|
||||
/// 覆盖面(Steps 测试先行):
|
||||
/// - load:`GET /projects` + `GET /prefs` → 分组行 + 收藏/折叠状态;
|
||||
/// - **merge 关键测试**:PUT body 必须保留 web/未来服务器写入的未知顶层键
|
||||
/// (UiPrefs 的 unknown-key-preserving API —— PUT 整体替换服务器 blob,
|
||||
/// 丢键=清库);
|
||||
/// - prefs 加载失败 → 项目照常显示、明确文案,且**绝不 PUT**(空底盘上写会
|
||||
/// 清掉服务器收藏);
|
||||
/// - PUT 失败 → 本地改动保留 + 同步失败文案;
|
||||
/// - 搜索中强制展开所有分组(镜像 web renderGrid 的 searching 规则);
|
||||
/// - "在此仓库开新会话" → ProjectOpenRequest{cwd + claude\r};相对路径拒绝。
|
||||
///
|
||||
/// 全部 HTTP 走真 `APIClient` over `FakeHTTPTransport`(零真实网络/等待)。
|
||||
@MainActor
|
||||
@Suite("ProjectsViewModel")
|
||||
struct ProjectsViewModelTests {
|
||||
private nonisolated static let base = "http://192.168.1.5:3000"
|
||||
|
||||
private nonisolated static let projectsJSON = """
|
||||
[{"name":"Billo.Platform.api","path":"/repos/api","isGit":true,"branch":"main",\
|
||||
"dirty":true,"lastActiveMs":200,"sessions":[]},\
|
||||
{"name":"Billo.Platform.web","path":"/repos/web","isGit":true,\
|
||||
"lastActiveMs":100,"sessions":[]}]
|
||||
"""
|
||||
|
||||
/// 未知顶层键 `webOnly` 是 merge 测试的探针。
|
||||
private nonisolated static let prefsJSON = """
|
||||
{"favourites":["/repos/web"],"collapsed":{"Billo.Platform":true},\
|
||||
"webOnly":{"theme":"dark"}}
|
||||
"""
|
||||
|
||||
// MARK: - Harness
|
||||
|
||||
private struct Fixture {
|
||||
let http: FakeHTTPTransport
|
||||
let host: HostRegistry.Host
|
||||
let viewModel: ProjectsViewModel
|
||||
let projectsURL: URL
|
||||
let prefsURL: URL
|
||||
}
|
||||
|
||||
private func makeFixture() throws -> Fixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
let http = FakeHTTPTransport()
|
||||
return Fixture(
|
||||
http: http,
|
||||
host: host,
|
||||
viewModel: ProjectsViewModel(host: host, http: http),
|
||||
projectsURL: try #require(URL(string: Self.base + "/projects")),
|
||||
prefsURL: try #require(URL(string: Self.base + "/prefs"))
|
||||
)
|
||||
}
|
||||
|
||||
private func loadHappyPath(_ fixture: Fixture) async throws {
|
||||
await fixture.http.queueSuccess(
|
||||
url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
url: fixture.prefsURL, body: Data(Self.prefsJSON.utf8)
|
||||
)
|
||||
await fixture.viewModel.load()
|
||||
}
|
||||
|
||||
private func recordedPutBodies(_ fixture: Fixture) async throws -> [[String: Any]] {
|
||||
let requests = await fixture.http.recordedRequests
|
||||
return try requests
|
||||
.filter { $0.httpMethod == "PUT" }
|
||||
.map { request in
|
||||
let body = try #require(request.httpBody)
|
||||
let json = try JSONSerialization.jsonObject(with: body)
|
||||
return try #require(json as? [String: Any])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - load
|
||||
|
||||
@Test("load 成功:项目解码 + prefs 应用(收藏排序、折叠状态、分组)")
|
||||
func loadAppliesProjectsAndPrefs() async throws {
|
||||
let fixture = try makeFixture()
|
||||
|
||||
try await loadHappyPath(fixture)
|
||||
|
||||
#expect(fixture.viewModel.hasLoadedOnce)
|
||||
#expect(fixture.viewModel.fetchErrorMessage == nil)
|
||||
#expect(fixture.viewModel.prefsErrorMessage == nil)
|
||||
#expect(fixture.viewModel.favourites == ["/repos/web"])
|
||||
#expect(fixture.viewModel.collapsedGroups == ["Billo.Platform": true])
|
||||
// 分组:两个同 namespace 项目成一组;收藏的 web 排在前(虽然更旧)。
|
||||
let groups = fixture.viewModel.groups
|
||||
#expect(groups.count == 1)
|
||||
#expect(groups[0].key == "Billo.Platform")
|
||||
#expect(groups[0].projects.map(\.path) == ["/repos/web", "/repos/api"])
|
||||
#expect(fixture.viewModel.isCollapsed(groups[0]))
|
||||
}
|
||||
|
||||
@Test("load:/projects 传输失败 → 显式错误文案;重试成功后清除且列表可用")
|
||||
func loadProjectsFailureIsExplicitAndRetryable() async throws {
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueFailure(
|
||||
url: fixture.projectsURL, error: URLError(.notConnectedToInternet)
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
url: fixture.prefsURL, body: Data(Self.prefsJSON.utf8)
|
||||
)
|
||||
|
||||
await fixture.viewModel.load()
|
||||
|
||||
#expect(fixture.viewModel.fetchErrorMessage != nil)
|
||||
#expect(fixture.viewModel.hasLoadedOnce == false)
|
||||
|
||||
await fixture.http.queueSuccess(
|
||||
url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
|
||||
)
|
||||
await fixture.viewModel.refresh()
|
||||
|
||||
#expect(fixture.viewModel.fetchErrorMessage == nil)
|
||||
#expect(fixture.viewModel.hasLoadedOnce)
|
||||
#expect(fixture.viewModel.groups.isEmpty == false)
|
||||
}
|
||||
|
||||
@Test("refresh 只重取 /projects,不再打 /prefs(prefs 留在内存,镜像 web init)")
|
||||
func refreshDoesNotRefetchPrefs() async throws {
|
||||
let fixture = try makeFixture()
|
||||
try await loadHappyPath(fixture)
|
||||
await fixture.http.queueSuccess(
|
||||
url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
|
||||
)
|
||||
|
||||
await fixture.viewModel.refresh()
|
||||
|
||||
let prefsGets = await fixture.http.recordedRequests.filter {
|
||||
$0.url == fixture.prefsURL && $0.httpMethod == "GET"
|
||||
}
|
||||
#expect(prefsGets.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - prefs 失败路径(clobber 防线)
|
||||
|
||||
@Test("prefs 加载失败:项目照常显示 + 明确文案;随后 toggle 只改本地、绝不 PUT")
|
||||
func prefsLoadFailureNeverPuts() async throws {
|
||||
let fixture = try makeFixture()
|
||||
await fixture.http.queueSuccess(
|
||||
url: fixture.projectsURL, body: Data(Self.projectsJSON.utf8)
|
||||
)
|
||||
await fixture.http.queueFailure(
|
||||
url: fixture.prefsURL, error: URLError(.timedOut)
|
||||
)
|
||||
await fixture.viewModel.load()
|
||||
|
||||
#expect(fixture.viewModel.prefsErrorMessage == ProjectsCopy.prefsLoadFailed)
|
||||
#expect(fixture.viewModel.groups.isEmpty == false)
|
||||
|
||||
await fixture.viewModel.toggleFavourite(path: "/repos/api")
|
||||
|
||||
#expect(fixture.viewModel.favourites == ["/repos/api"]) // 本地仍生效
|
||||
let puts = await fixture.http.recordedRequests.filter { $0.httpMethod == "PUT" }
|
||||
#expect(puts.isEmpty) // 空底盘上 PUT = 清掉服务器 blob,绝不允许
|
||||
}
|
||||
|
||||
// MARK: - favourites / collapse 往返(THE merge 测试)
|
||||
|
||||
@Test("toggleFavourite:PUT body 保留未知键 webOnly + collapsed 原样 + Origin 铁律")
|
||||
func toggleFavouritePreservesUnknownKeysInPutBody() async throws {
|
||||
let fixture = try makeFixture()
|
||||
try await loadHappyPath(fixture)
|
||||
let echo = """
|
||||
{"favourites":["/repos/web","/repos/api"],"collapsed":{"Billo.Platform":true}}
|
||||
"""
|
||||
await fixture.http.queueSuccess(
|
||||
method: "PUT", url: fixture.prefsURL, body: Data(echo.utf8)
|
||||
)
|
||||
|
||||
await fixture.viewModel.toggleFavourite(path: "/repos/api")
|
||||
|
||||
let bodies = try await recordedPutBodies(fixture)
|
||||
#expect(bodies.count == 1)
|
||||
let body = try #require(bodies.first)
|
||||
#expect(body["favourites"] as? [String] == ["/repos/web", "/repos/api"])
|
||||
#expect(body["collapsed"] as? [String: Bool] == ["Billo.Platform": true])
|
||||
#expect(body["webOnly"] as? [String: String] == ["theme": "dark"]) // 未知键保留
|
||||
let put = try #require(
|
||||
await fixture.http.recordedRequests.first { $0.httpMethod == "PUT" }
|
||||
)
|
||||
#expect(
|
||||
put.value(forHTTPHeaderField: "Origin") == fixture.host.endpoint.originHeader
|
||||
)
|
||||
// 采纳服务器 echo 为新真相。
|
||||
#expect(fixture.viewModel.favourites == ["/repos/web", "/repos/api"])
|
||||
#expect(fixture.viewModel.prefsSyncErrorMessage == nil)
|
||||
}
|
||||
|
||||
@Test("toggleFavourite 取消收藏:从数组移除,其余顺序不变")
|
||||
func toggleFavouriteRemoves() async throws {
|
||||
let fixture = try makeFixture()
|
||||
try await loadHappyPath(fixture)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "PUT", url: fixture.prefsURL,
|
||||
body: Data(#"{"favourites":[],"collapsed":{"Billo.Platform":true}}"#.utf8)
|
||||
)
|
||||
|
||||
await fixture.viewModel.toggleFavourite(path: "/repos/web")
|
||||
|
||||
let body = try #require(try await recordedPutBodies(fixture).first)
|
||||
#expect(body["favourites"] as? [String] == [])
|
||||
}
|
||||
|
||||
@Test("toggleCollapsed:展开只删自己的 key;favourites 与未知键原样保留")
|
||||
func toggleCollapsedRemovesOnlyItsKey() async throws {
|
||||
let fixture = try makeFixture()
|
||||
try await loadHappyPath(fixture)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "PUT", url: fixture.prefsURL,
|
||||
body: Data(#"{"favourites":["/repos/web"],"collapsed":{}}"#.utf8)
|
||||
)
|
||||
|
||||
await fixture.viewModel.toggleCollapsed(key: "Billo.Platform") // 展开
|
||||
|
||||
let body = try #require(try await recordedPutBodies(fixture).first)
|
||||
#expect(body["collapsed"] as? [String: Bool] == [:])
|
||||
#expect(body["favourites"] as? [String] == ["/repos/web"])
|
||||
#expect(body["webOnly"] as? [String: String] == ["theme": "dark"])
|
||||
#expect(fixture.viewModel.collapsedGroups == [:])
|
||||
}
|
||||
|
||||
@Test("PUT 失败:本地改动保留 + 同步失败文案(下次 toggle 重试)")
|
||||
func putFailureKeepsLocalChangeAndSurfacesError() async throws {
|
||||
let fixture = try makeFixture()
|
||||
try await loadHappyPath(fixture)
|
||||
await fixture.http.queueFailure(
|
||||
method: "PUT", url: fixture.prefsURL, error: URLError(.timedOut)
|
||||
)
|
||||
|
||||
await fixture.viewModel.toggleFavourite(path: "/repos/api")
|
||||
|
||||
#expect(fixture.viewModel.favourites == ["/repos/web", "/repos/api"])
|
||||
#expect(fixture.viewModel.prefsSyncErrorMessage != nil)
|
||||
}
|
||||
|
||||
// MARK: - 折叠与搜索
|
||||
|
||||
@Test("isCollapsed:搜索中强制展开(结果绝不藏在折叠 caret 后)")
|
||||
func searchingForcesSectionsOpen() async throws {
|
||||
let fixture = try makeFixture()
|
||||
try await loadHappyPath(fixture)
|
||||
let group = try #require(fixture.viewModel.groups.first)
|
||||
#expect(fixture.viewModel.isCollapsed(group))
|
||||
|
||||
fixture.viewModel.searchText = "api"
|
||||
|
||||
let searched = try #require(fixture.viewModel.groups.first)
|
||||
#expect(fixture.viewModel.isCollapsed(searched) == false)
|
||||
}
|
||||
|
||||
// MARK: - 在此仓库开新会话
|
||||
|
||||
@Test("requestOpenClaude:绝对路径 → OpenRequest{host,cwd,claude\\r}")
|
||||
func requestOpenClaudeMintsRequest() async throws {
|
||||
let fixture = try makeFixture()
|
||||
|
||||
fixture.viewModel.requestOpenClaude(cwd: "/repos/api")
|
||||
|
||||
let request = try #require(fixture.viewModel.openRequest)
|
||||
#expect(request.host == fixture.host)
|
||||
#expect(request.cwd == "/repos/api")
|
||||
#expect(request.bootstrapInput == ProjectLaunch.claudeBootstrapInput)
|
||||
#expect(ProjectLaunch.claudeBootstrapInput == "claude\r") // Enter 是 \r 不是 \n
|
||||
}
|
||||
|
||||
@Test("requestOpenClaude:相对路径拒绝(服务器数据不可信)→ 无请求 + 文案")
|
||||
func requestOpenClaudeRejectsRelativePath() async throws {
|
||||
let fixture = try makeFixture()
|
||||
|
||||
fixture.viewModel.requestOpenClaude(cwd: "repos/api")
|
||||
|
||||
#expect(fixture.viewModel.openRequest == nil)
|
||||
#expect(fixture.viewModel.openErrorMessage == ProjectsCopy.openClaudeInvalidPath)
|
||||
}
|
||||
}
|
||||
366
ios/App/WebTermTests/PushRegistrarTests.swift
Normal file
366
ios/App/WebTermTests/PushRegistrarTests.swift
Normal file
@@ -0,0 +1,366 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import Testing
|
||||
import TestSupport
|
||||
import UserNotifications
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-21 · WEBTERM_GATE category 注册形状(安全注:Allow 必带
|
||||
/// `.authenticationRequired`、两动作均不带 `.foreground`)。
|
||||
@MainActor
|
||||
@Suite("GateNotificationCategory")
|
||||
struct GateNotificationCategoryTests {
|
||||
@Test("category id 与服务器 GATE_CATEGORY 一致(src/push/apns.ts:52)")
|
||||
func categoryIdentifierMatchesServer() {
|
||||
#expect(GateNotificationCategory.category().identifier == "WEBTERM_GATE")
|
||||
}
|
||||
|
||||
@Test("恰好两动作,顺序 Allow→Deny,标题为中文具名常量")
|
||||
func actionsShapeAndTitles() {
|
||||
let actions = GateNotificationCategory.category().actions
|
||||
#expect(actions.count == 2)
|
||||
#expect(actions.first?.identifier == GateNotificationCategory.allowActionId)
|
||||
#expect(actions.last?.identifier == GateNotificationCategory.denyActionId)
|
||||
#expect(actions.first?.title == GateNotificationCategory.allowTitle)
|
||||
#expect(actions.last?.title == GateNotificationCategory.denyTitle)
|
||||
}
|
||||
|
||||
@Test("安全注:Allow 必须带 .authenticationRequired(锁屏批准 = 授权主机执行命令)")
|
||||
func allowRequiresAuthentication() {
|
||||
let allow = GateNotificationCategory.category().actions.first
|
||||
#expect(allow?.options.contains(.authenticationRequired) == true)
|
||||
}
|
||||
|
||||
@Test("安全注:Deny 保持免认证(fail-safe——旁观者只能拒绝)")
|
||||
func denyStaysUnauthenticated() {
|
||||
let deny = GateNotificationCategory.category().actions.last
|
||||
#expect(deny?.options.contains(.authenticationRequired) == false)
|
||||
}
|
||||
|
||||
@Test("安全注:两动作都不带 .foreground(锁屏两次手势闭环,不拉起 UI)")
|
||||
func neitherActionForegrounds() {
|
||||
let actions = GateNotificationCategory.category().actions
|
||||
for action in actions {
|
||||
#expect(!action.options.contains(.foreground))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// T-iOS-21 · PushRegistrar:授权流程(真授权而非 provisional,因锁屏动作需要
|
||||
/// alert 级展示)、device token → 每个已配对主机逐一注册、失败续命、移除钩子。
|
||||
@MainActor
|
||||
@Suite("PushRegistrar")
|
||||
struct PushRegistrarTests {
|
||||
// MARK: - Fakes(seam 替身;真 UNUserNotificationCenter 无法在单测实例化流程)
|
||||
|
||||
@MainActor
|
||||
private final class FakeNotificationCenter: NotificationCenterClient {
|
||||
var status: UNAuthorizationStatus = .notDetermined
|
||||
var grantResult: Result<Bool, any Error> = .success(true)
|
||||
private(set) var requestedOptions: [UNAuthorizationOptions] = []
|
||||
private(set) var registeredCategorySets: [Set<UNNotificationCategory>] = []
|
||||
private(set) var addedRequests: [UNNotificationRequest] = []
|
||||
|
||||
func authorizationStatus() async -> UNAuthorizationStatus { status }
|
||||
|
||||
func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool {
|
||||
requestedOptions.append(options)
|
||||
return try grantResult.get()
|
||||
}
|
||||
|
||||
func setNotificationCategories(_ categories: Set<UNNotificationCategory>) {
|
||||
registeredCategorySets.append(categories)
|
||||
}
|
||||
|
||||
func add(_ request: UNNotificationRequest) async throws {
|
||||
addedRequests.append(request)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeRemoteRegistrar: RemoteNotificationRegistering {
|
||||
private(set) var registerCallCount = 0
|
||||
func registerForRemoteNotifications() { registerCallCount += 1 }
|
||||
}
|
||||
|
||||
private enum StubError: Error { case authorizationFailed }
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static let hostABase = "http://192.168.1.5:3000"
|
||||
private static let hostBBase = "http://192.168.1.6:3000"
|
||||
/// 32 字节 device token → 64 位小写 hex(APNs 现行长度)。
|
||||
private static let tokenData = Data((0..<32).map { UInt8($0) })
|
||||
private static let tokenHex = tokenData.map { String(format: "%02x", $0) }.joined()
|
||||
|
||||
private static func makeHost(base: String, name: String = "mac") throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: name, endpoint: endpoint)
|
||||
}
|
||||
|
||||
private static func apnsTokenURL(base: String) throws -> URL {
|
||||
try #require(URL(string: "\(base)/push/apns-token"))
|
||||
}
|
||||
|
||||
private static func makeRegistrar(
|
||||
hosts: [HostRegistry.Host],
|
||||
http: FakeHTTPTransport,
|
||||
center: FakeNotificationCenter,
|
||||
remote: FakeRemoteRegistrar
|
||||
) -> PushRegistrar {
|
||||
PushRegistrar(
|
||||
hostStore: InMemoryHostStore(hosts: hosts),
|
||||
http: http,
|
||||
center: center,
|
||||
remote: remote
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - activate():授权流程
|
||||
|
||||
@Test("activate → 注册含 WEBTERM_GATE 的 category 集合")
|
||||
func activateRegistersGateCategory() async throws {
|
||||
let center = FakeNotificationCenter()
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [], http: FakeHTTPTransport(), center: center, remote: FakeRemoteRegistrar()
|
||||
)
|
||||
|
||||
await registrar.activate()
|
||||
|
||||
let registered = try #require(center.registeredCategorySets.first)
|
||||
#expect(registered.contains(where: { $0.identifier == GateNotificationCategory.identifier }))
|
||||
}
|
||||
|
||||
@Test("无已配对主机 → 不请求授权、不注册远程通知(无推送来源不打扰)")
|
||||
func activateWithoutHostsSkipsAuthorization() async {
|
||||
let center = FakeNotificationCenter()
|
||||
let remote = FakeRemoteRegistrar()
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [], http: FakeHTTPTransport(), center: center, remote: remote
|
||||
)
|
||||
|
||||
await registrar.activate()
|
||||
|
||||
#expect(center.requestedOptions.isEmpty)
|
||||
#expect(remote.registerCallCount == 0)
|
||||
}
|
||||
|
||||
@Test("有主机 + notDetermined + 授予 → 以 [.alert,.sound] 请求(真授权,非 provisional)并注册远程通知")
|
||||
func activateRequestsRealAuthorizationAndRegisters() async throws {
|
||||
let center = FakeNotificationCenter()
|
||||
let remote = FakeRemoteRegistrar()
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [try Self.makeHost(base: Self.hostABase)],
|
||||
http: FakeHTTPTransport(), center: center, remote: remote
|
||||
)
|
||||
|
||||
await registrar.activate()
|
||||
|
||||
#expect(center.requestedOptions == [[.alert, .sound]])
|
||||
// provisional 只静默进通知中心,锁屏 Allow/Deny 需要 alert 级授权。
|
||||
#expect(center.requestedOptions.first?.contains(.provisional) == false)
|
||||
#expect(remote.registerCallCount == 1)
|
||||
}
|
||||
|
||||
@Test("用户拒绝授权 → 不注册远程通知、不 crash")
|
||||
func activateDeniedGrantSkipsRegistration() async throws {
|
||||
let center = FakeNotificationCenter()
|
||||
center.grantResult = .success(false)
|
||||
let remote = FakeRemoteRegistrar()
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [try Self.makeHost(base: Self.hostABase)],
|
||||
http: FakeHTTPTransport(), center: center, remote: remote
|
||||
)
|
||||
|
||||
await registrar.activate()
|
||||
|
||||
#expect(remote.registerCallCount == 0)
|
||||
}
|
||||
|
||||
@Test("授权请求抛错 → 记日志、不注册、不 crash")
|
||||
func activateAuthorizationErrorHandled() async throws {
|
||||
let center = FakeNotificationCenter()
|
||||
center.grantResult = .failure(StubError.authorizationFailed)
|
||||
let remote = FakeRemoteRegistrar()
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [try Self.makeHost(base: Self.hostABase)],
|
||||
http: FakeHTTPTransport(), center: center, remote: remote
|
||||
)
|
||||
|
||||
await registrar.activate()
|
||||
|
||||
#expect(remote.registerCallCount == 0)
|
||||
}
|
||||
|
||||
@Test("状态已 denied → 不再弹请求,也不注册")
|
||||
func activateDeniedStatusShortCircuits() async throws {
|
||||
let center = FakeNotificationCenter()
|
||||
center.status = .denied
|
||||
let remote = FakeRemoteRegistrar()
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [try Self.makeHost(base: Self.hostABase)],
|
||||
http: FakeHTTPTransport(), center: center, remote: remote
|
||||
)
|
||||
|
||||
await registrar.activate()
|
||||
|
||||
#expect(center.requestedOptions.isEmpty)
|
||||
#expect(remote.registerCallCount == 0)
|
||||
}
|
||||
|
||||
@Test("状态已 authorized → 跳过请求直接注册远程通知")
|
||||
func activateAuthorizedStatusRegistersDirectly() async throws {
|
||||
let center = FakeNotificationCenter()
|
||||
center.status = .authorized
|
||||
let remote = FakeRemoteRegistrar()
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [try Self.makeHost(base: Self.hostABase)],
|
||||
http: FakeHTTPTransport(), center: center, remote: remote
|
||||
)
|
||||
|
||||
await registrar.activate()
|
||||
|
||||
#expect(center.requestedOptions.isEmpty)
|
||||
#expect(remote.registerCallCount == 1)
|
||||
}
|
||||
|
||||
// MARK: - handleDeviceToken:逐主机注册
|
||||
|
||||
@Test("device token → 小写 hex,并对每个已配对主机 POST /push/apns-token(带 Origin)")
|
||||
func deviceTokenRegistersWithEveryHost() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let http = FakeHTTPTransport()
|
||||
await http.queueSuccess(
|
||||
method: "POST", url: try Self.apnsTokenURL(base: Self.hostABase), status: 204
|
||||
)
|
||||
await http.queueSuccess(
|
||||
method: "POST", url: try Self.apnsTokenURL(base: Self.hostBBase), status: 204
|
||||
)
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [hostA, hostB], http: http,
|
||||
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
|
||||
)
|
||||
|
||||
await registrar.handleDeviceToken(Self.tokenData)
|
||||
|
||||
#expect(registrar.currentTokenHex == Self.tokenHex)
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 2)
|
||||
let urls = Set(requests.compactMap { $0.url?.absoluteString })
|
||||
#expect(urls == [
|
||||
"\(Self.hostABase)/push/apns-token", "\(Self.hostBBase)/push/apns-token",
|
||||
])
|
||||
for request in requests {
|
||||
#expect(request.httpMethod == "POST")
|
||||
// G 端点铁律:Origin 必与 endpoint.originHeader 逐字节一致。
|
||||
let origin = request.value(forHTTPHeaderField: "Origin")
|
||||
#expect(origin == request.url?.absoluteString.replacingOccurrences(
|
||||
of: "/push/apns-token", with: ""
|
||||
))
|
||||
let body = try #require(request.httpBody)
|
||||
let decoded = try JSONDecoder().decode([String: String].self, from: body)
|
||||
#expect(decoded == ["token": Self.tokenHex])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("一主机失败 → 其余主机照常注册;同 token 重试只补失败的主机")
|
||||
func failedHostRetriedNextTime() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let hostB = try Self.makeHost(base: Self.hostBBase, name: "mini")
|
||||
let http = FakeHTTPTransport()
|
||||
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
|
||||
let urlB = try Self.apnsTokenURL(base: Self.hostBBase)
|
||||
await http.queueFailure(method: "POST", url: urlA, error: URLError(.cannotConnectToHost))
|
||||
await http.queueSuccess(method: "POST", url: urlB, status: 204)
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [hostA, hostB], http: http,
|
||||
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
|
||||
)
|
||||
|
||||
await registrar.handleDeviceToken(Self.tokenData)
|
||||
#expect(await http.recordedRequests.count == 2)
|
||||
|
||||
// 第二次同 token 送达(下次启动/激活的重试路径):只补 hostA。
|
||||
await http.queueSuccess(method: "POST", url: urlA, status: 204)
|
||||
await registrar.handleDeviceToken(Self.tokenData)
|
||||
|
||||
let requests = await http.recordedRequests
|
||||
#expect(requests.count == 3)
|
||||
#expect(requests.last?.url == urlA)
|
||||
}
|
||||
|
||||
@Test("token 变化 → 对全部主机重新注册")
|
||||
func tokenChangeReRegistersAllHosts() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let http = FakeHTTPTransport()
|
||||
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
|
||||
await http.queueSuccess(method: "POST", url: urlA, status: 204)
|
||||
await http.queueSuccess(method: "POST", url: urlA, status: 204)
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [hostA], http: http,
|
||||
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
|
||||
)
|
||||
|
||||
await registrar.handleDeviceToken(Self.tokenData)
|
||||
let changed = Data((0..<32).map { UInt8($0 &+ 1) })
|
||||
await registrar.handleDeviceToken(changed)
|
||||
|
||||
#expect(await http.recordedRequests.count == 2)
|
||||
#expect(registrar.currentTokenHex == changed.map { String(format: "%02x", $0) }.joined())
|
||||
}
|
||||
|
||||
// MARK: - 主机移除钩子(additive hook:目前无 UI 移除路径,见任务决策记录)
|
||||
|
||||
@Test("handleHostRemoved(有 token)→ 对该主机 DELETE /push/apns-token")
|
||||
func hostRemovedUnregistersToken() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let http = FakeHTTPTransport()
|
||||
let urlA = try Self.apnsTokenURL(base: Self.hostABase)
|
||||
await http.queueSuccess(method: "POST", url: urlA, status: 204)
|
||||
await http.queueSuccess(method: "DELETE", url: urlA, status: 204)
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [hostA], http: http,
|
||||
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
|
||||
)
|
||||
await registrar.handleDeviceToken(Self.tokenData)
|
||||
|
||||
await registrar.handleHostRemoved(hostA)
|
||||
|
||||
let last = try #require(await http.recordedRequests.last)
|
||||
#expect(last.httpMethod == "DELETE")
|
||||
#expect(last.url == urlA)
|
||||
let body = try #require(last.httpBody)
|
||||
#expect(try JSONDecoder().decode([String: String].self, from: body)
|
||||
== ["token": Self.tokenHex])
|
||||
}
|
||||
|
||||
@Test("handleHostRemoved(无 token)→ 不发任何请求")
|
||||
func hostRemovedWithoutTokenIsNoOp() async throws {
|
||||
let hostA = try Self.makeHost(base: Self.hostABase)
|
||||
let http = FakeHTTPTransport()
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [hostA], http: http,
|
||||
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
|
||||
)
|
||||
|
||||
await registrar.handleHostRemoved(hostA)
|
||||
|
||||
#expect(await http.recordedRequests.isEmpty)
|
||||
}
|
||||
|
||||
@Test("远程注册失败回调 → 只记日志,不 crash")
|
||||
func registrationFailureLoggedOnly() throws {
|
||||
let registrar = Self.makeRegistrar(
|
||||
hosts: [], http: FakeHTTPTransport(),
|
||||
center: FakeNotificationCenter(), remote: FakeRemoteRegistrar()
|
||||
)
|
||||
|
||||
registrar.handleRegistrationFailure(URLError(.notConnectedToInternet))
|
||||
|
||||
#expect(registrar.currentTokenHex == nil)
|
||||
}
|
||||
}
|
||||
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 == "管理常用语")
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ struct SessionListViewModelTests {
|
||||
hostStore: InMemoryHostStore(hosts: hosts),
|
||||
http: http,
|
||||
clock: clock,
|
||||
unreadStore: InMemoryUnreadWatermarkStore(), // T-iOS-23 seam
|
||||
nowMs: nowMs
|
||||
)
|
||||
}
|
||||
@@ -233,6 +234,7 @@ struct SessionListViewModelTests {
|
||||
hostStore: ThrowingHostStore(),
|
||||
http: FakeHTTPTransport(),
|
||||
clock: FakeClock(),
|
||||
unreadStore: InMemoryUnreadWatermarkStore(),
|
||||
nowMs: { Self.nowMs }
|
||||
)
|
||||
|
||||
|
||||
411
ios/App/WebTermTests/SessionSwitcherTests.swift
Normal file
411
ios/App/WebTermTests/SessionSwitcherTests.swift
Normal file
@@ -0,0 +1,411 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import HostRegistry
|
||||
import SessionCore
|
||||
import TestSupport
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-23 · 多会话切换器:unread 点(`lastOutputAt` vs 本地水位,经
|
||||
/// `UnreadLedger` + App 侧 UserDefaults 持久化)+ OSC 标题(SwiftTerm
|
||||
/// `setTerminalTitle` delegate → `TitleSanitizer` → 列表行)。
|
||||
///
|
||||
/// 分层:
|
||||
/// - 列表 VM:unread 判定/清除/持久化、标题注册表(净化在两个边界各自执行);
|
||||
/// - TerminalViewModel:delegate 原始标题在此边界净化,adopted 后携 id 上浮;
|
||||
/// - 接线:controller 把 title hook 接到新旧 VM(suspend→resume 重建后不掉线);
|
||||
/// coordinator 的 closeTerminal 记 last-seen 水位、title 流入列表行。
|
||||
/// 单活 WS 不变式:切会话 = close→open(deep-link 路径 `openDeepLinkedSession`
|
||||
/// 与列表返回→再开共用 `closeTerminal`,一次只有一个 engine —— 见接线用例)。
|
||||
@MainActor
|
||||
@Suite("SessionSwitcher (T-iOS-23)")
|
||||
struct SessionSwitcherTests {
|
||||
private nonisolated static let base = "http://192.168.1.5:3000"
|
||||
private nonisolated static let nowMs = 1_700_000_100_000
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
private func makeHost() throws -> HostRegistry.Host {
|
||||
let url = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: url))
|
||||
return HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
}
|
||||
|
||||
private func listURL() throws -> URL {
|
||||
try #require(URL(string: "\(Self.base)/live-sessions"))
|
||||
}
|
||||
|
||||
private func sessionJSON(id: UUID, lastOutputAt: Int? = nil, cwd: String = "/Users/dev/proj") -> String {
|
||||
let lastOutputJSON = lastOutputAt.map { ",\"lastOutputAt\":\($0)" } ?? ""
|
||||
return "{\"id\":\"\(id.uuidString.lowercased())\",\"createdAt\":1700000000000"
|
||||
+ ",\"clientCount\":1,\"status\":\"working\",\"exited\":false"
|
||||
+ ",\"cwd\":\"\(cwd)\",\"cols\":80,\"rows\":24\(lastOutputJSON)}"
|
||||
}
|
||||
|
||||
private func listBody(_ entries: [String]) -> Data {
|
||||
Data("[\(entries.joined(separator: ","))]".utf8)
|
||||
}
|
||||
|
||||
private func makeListViewModel(
|
||||
hosts: [HostRegistry.Host],
|
||||
http: any HTTPTransport,
|
||||
unreadStore: any UnreadWatermarkStore
|
||||
) -> SessionListViewModel {
|
||||
SessionListViewModel(
|
||||
hostStore: InMemoryHostStore(hosts: hosts),
|
||||
http: http,
|
||||
clock: FakeClock(),
|
||||
unreadStore: unreadStore,
|
||||
nowMs: { Self.nowMs }
|
||||
)
|
||||
}
|
||||
|
||||
private func loadOnce(_ viewModel: SessionListViewModel) async {
|
||||
await viewModel.reloadHosts()
|
||||
await viewModel.refresh()
|
||||
}
|
||||
|
||||
// MARK: - 列表 unread 点(水位判定 + 清除 + 复燃)
|
||||
|
||||
@Test("lastOutputAt 高于水位 → unread 点亮;markSeen 熄灭;更新的输出再点亮")
|
||||
func unreadDotLifecycle() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let host = try makeHost()
|
||||
let store = InMemoryUnreadWatermarkStore()
|
||||
let sessionId = UUID()
|
||||
let url = try listURL()
|
||||
await http.queueSuccess(
|
||||
url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)])
|
||||
)
|
||||
let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store)
|
||||
|
||||
// Act & Assert: 从未看过 → unread。
|
||||
await loadOnce(viewModel)
|
||||
#expect(viewModel.rows.first?.isUnread == true)
|
||||
|
||||
// Act & Assert: 标记已读 → 立即熄灭(不等下一次轮询)。
|
||||
viewModel.markSeen(sessionId: sessionId)
|
||||
#expect(viewModel.rows.first?.isUnread == false)
|
||||
|
||||
// Act & Assert: 服务器又有新输出 → 复燃。
|
||||
await http.queueSuccess(
|
||||
url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs + 5_000)])
|
||||
)
|
||||
await viewModel.refresh()
|
||||
#expect(viewModel.rows.first?.isUnread == true)
|
||||
}
|
||||
|
||||
@Test("lastOutputAt 缺席(旧服务器)→ 绝不点灯")
|
||||
func absentLastOutputAtNeverLights() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let host = try makeHost()
|
||||
let sessionId = UUID()
|
||||
await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)]))
|
||||
let viewModel = makeListViewModel(
|
||||
hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore()
|
||||
)
|
||||
|
||||
// Act
|
||||
await loadOnce(viewModel)
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.rows.first?.isUnread == false)
|
||||
}
|
||||
|
||||
@Test("markSeen 持久化水位;新 VM(重启模拟)读回后照样已读")
|
||||
func watermarkPersistsAcrossViewModels() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let host = try makeHost()
|
||||
let store = InMemoryUnreadWatermarkStore()
|
||||
let sessionId = UUID()
|
||||
let url = try listURL()
|
||||
let outputAt = Self.nowMs - 1_000
|
||||
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)]))
|
||||
let first = makeListViewModel(hosts: [host], http: http, unreadStore: store)
|
||||
await loadOnce(first)
|
||||
|
||||
// Act: 标记已读 → 存储必须收到水位。
|
||||
first.markSeen(sessionId: sessionId)
|
||||
#expect(store.snapshot[sessionId] == Self.nowMs)
|
||||
|
||||
// Arrange: "重启"——新 VM 从同一 store 载入。
|
||||
await http.queueSuccess(url: url, body: listBody([sessionJSON(id: sessionId, lastOutputAt: outputAt)]))
|
||||
let second = makeListViewModel(hosts: [host], http: http, unreadStore: store)
|
||||
|
||||
// Act & Assert
|
||||
await loadOnce(second)
|
||||
#expect(second.rows.first?.isUnread == false)
|
||||
}
|
||||
|
||||
@Test("点开会话行(openSession)即记 last-seen —— 点即熄灭")
|
||||
func openSessionMarksSeen() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let host = try makeHost()
|
||||
let store = InMemoryUnreadWatermarkStore()
|
||||
let sessionId = UUID()
|
||||
await http.queueSuccess(
|
||||
url: try listURL(),
|
||||
body: listBody([sessionJSON(id: sessionId, lastOutputAt: Self.nowMs - 1_000)])
|
||||
)
|
||||
let viewModel = makeListViewModel(hosts: [host], http: http, unreadStore: store)
|
||||
await loadOnce(viewModel)
|
||||
#expect(viewModel.rows.first?.isUnread == true)
|
||||
|
||||
// Act
|
||||
viewModel.openSession(id: sessionId)
|
||||
|
||||
// Assert: 导航信号照发,同时水位已记、点已熄。
|
||||
#expect(viewModel.openRequest?.sessionId == sessionId)
|
||||
#expect(viewModel.rows.first?.isUnread == false)
|
||||
#expect(store.snapshot[sessionId] == Self.nowMs)
|
||||
}
|
||||
|
||||
// MARK: - 列表标题注册表(敌意标题在列表边界再净化一次)
|
||||
|
||||
@Test("setSessionTitle 净化敌意标题后上行;净化后为空 → 回退 cwd 名(title nil)")
|
||||
func sessionTitleRegistrySanitizes() async throws {
|
||||
// Arrange
|
||||
let http = FakeHTTPTransport()
|
||||
let host = try makeHost()
|
||||
let sessionId = UUID()
|
||||
await http.queueSuccess(url: try listURL(), body: listBody([sessionJSON(id: sessionId)]))
|
||||
let viewModel = makeListViewModel(
|
||||
hosts: [host], http: http, unreadStore: InMemoryUnreadWatermarkStore()
|
||||
)
|
||||
await loadOnce(viewModel)
|
||||
#expect(viewModel.rows.first?.title == nil)
|
||||
|
||||
// Act: RLO + ESC 注入的敌意标题直接打列表接口(不信任上游净化)。
|
||||
viewModel.setSessionTitle(sessionId: sessionId, title: "npm \u{202E}run\u{1B}[2J build")
|
||||
|
||||
// Assert
|
||||
#expect(viewModel.rows.first?.title == "npm run[2J build")
|
||||
|
||||
// Act & Assert: 全零宽标题 → 清空注册表条目,回退 nil。
|
||||
viewModel.setSessionTitle(sessionId: sessionId, title: "\u{200B}\u{200D}")
|
||||
#expect(viewModel.rows.first?.title == nil)
|
||||
}
|
||||
|
||||
// MARK: - TerminalViewModel:delegate 边界净化 + adopted 后携 id 上浮
|
||||
|
||||
/// TerminalViewModelTests 的同款 harness:真 engine over FakeTransport。
|
||||
@MainActor
|
||||
private final class TerminalHarness {
|
||||
let transport = FakeTransport()
|
||||
let engine: SessionEngine
|
||||
let viewModel: TerminalViewModel
|
||||
|
||||
init() throws {
|
||||
let baseURL = try #require(URL(string: SessionSwitcherTests.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
engine = SessionEngine(
|
||||
transport: transport, clock: FakeClock(), endpoint: endpoint,
|
||||
eventsSource: { _ in [] }
|
||||
)
|
||||
viewModel = TerminalViewModel(engine: engine, events: engine.events)
|
||||
viewModel.start()
|
||||
}
|
||||
|
||||
func openAndAdopt(_ id: UUID) async {
|
||||
await engine.open(sessionId: nil, cwd: nil)
|
||||
await transport.emit(
|
||||
frame: #"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
|
||||
)
|
||||
await viewModel.waitUntilProcessed(eventCount: 3)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("setTerminalTitle:敌意原始标题在 VM 边界净化,terminalTitle 暴露净化值并携 adopted id 上浮")
|
||||
func terminalTitleSanitizedAndForwarded() async throws {
|
||||
// Arrange
|
||||
let harness = try TerminalHarness()
|
||||
let adopted = UUID()
|
||||
var forwarded: [(UUID, String)] = []
|
||||
harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }
|
||||
await harness.openAndAdopt(adopted)
|
||||
|
||||
// Act: SwiftTerm delegate 原样送来的敌意 OSC 标题。
|
||||
harness.viewModel.setTerminalTitle(" claude\u{202E}\u{07} · fix bug ")
|
||||
|
||||
// Assert
|
||||
#expect(harness.viewModel.terminalTitle == "claude · fix bug")
|
||||
#expect(forwarded.count == 1)
|
||||
#expect(forwarded.first?.0 == adopted)
|
||||
#expect(forwarded.first?.1 == "claude · fix bug")
|
||||
}
|
||||
|
||||
@Test("adopted 前到达的标题被暂存,adopted 后补发一次(不丢、不提前)")
|
||||
func titleBeforeAdoptionIsHeldThenForwarded() async throws {
|
||||
// Arrange
|
||||
let harness = try TerminalHarness()
|
||||
let adopted = UUID()
|
||||
var forwarded: [(UUID, String)] = []
|
||||
harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }
|
||||
|
||||
// Act: 标题先到(防御路径——真实线序 attached 永远在 output 前)。
|
||||
harness.viewModel.setTerminalTitle("early")
|
||||
#expect(forwarded.isEmpty)
|
||||
await harness.openAndAdopt(adopted)
|
||||
|
||||
// Assert
|
||||
#expect(forwarded.count == 1)
|
||||
#expect(forwarded.first?.0 == adopted)
|
||||
#expect(forwarded.first?.1 == "early")
|
||||
}
|
||||
|
||||
@Test("标题被清空(净化后空串)→ terminalTitle 归 nil,上浮空串让注册表清条目")
|
||||
func clearedTitleForwardsEmpty() async throws {
|
||||
// Arrange
|
||||
let harness = try TerminalHarness()
|
||||
let adopted = UUID()
|
||||
var forwarded: [(UUID, String)] = []
|
||||
harness.viewModel.onTitleChanged = { forwarded.append(($0, $1)) }
|
||||
await harness.openAndAdopt(adopted)
|
||||
harness.viewModel.setTerminalTitle("work")
|
||||
|
||||
// Act
|
||||
harness.viewModel.setTerminalTitle(" ")
|
||||
|
||||
// Assert
|
||||
#expect(harness.viewModel.terminalTitle == nil)
|
||||
#expect(forwarded.map(\.1) == ["work", ""])
|
||||
}
|
||||
|
||||
// MARK: - 接线:controller 挂 hook(重建后不掉)+ coordinator 全链路
|
||||
|
||||
private struct WiringFixture {
|
||||
let transport: FakeTransport
|
||||
let http: FakeHTTPTransport
|
||||
let host: HostRegistry.Host
|
||||
let unreadStore: InMemoryUnreadWatermarkStore
|
||||
let environment: AppEnvironment
|
||||
}
|
||||
|
||||
private func makeWiringFixture(suiteName: String) throws -> WiringFixture {
|
||||
let baseURL = try #require(URL(string: Self.base))
|
||||
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
|
||||
let transport = FakeTransport()
|
||||
let http = FakeHTTPTransport()
|
||||
let unreadStore = InMemoryUnreadWatermarkStore()
|
||||
let host = HostRegistry.Host(id: UUID(), name: "书房 Mac", endpoint: endpoint)
|
||||
let defaults = try #require(UserDefaults(suiteName: suiteName))
|
||||
return WiringFixture(
|
||||
transport: transport,
|
||||
http: http,
|
||||
host: host,
|
||||
unreadStore: unreadStore,
|
||||
environment: AppEnvironment(
|
||||
hostStore: InMemoryHostStore(hosts: [host]),
|
||||
lastSessionStore: UserDefaultsLastSessionStore(defaults: defaults),
|
||||
http: http,
|
||||
termTransport: transport,
|
||||
probe: { _ in .failure(.timeout) },
|
||||
unreadStore: unreadStore
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func adopt(
|
||||
_ controller: TerminalSessionController,
|
||||
transport: FakeTransport,
|
||||
sessionId: UUID
|
||||
) async {
|
||||
await controller.openTask?.value
|
||||
await transport.emit(
|
||||
frame: #"{"type":"attached","sessionId":"\#(sessionId.uuidString.lowercased())"}"#
|
||||
)
|
||||
await controller.terminalViewModel.waitUntilProcessed(eventCount: 3)
|
||||
}
|
||||
|
||||
@Test("controller 把 title hook 接到 VM;suspend→resume 重建后新 VM 依然上浮")
|
||||
func controllerRewiresTitleHookAcrossRebuild() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.rewire")
|
||||
let adopted = UUID()
|
||||
var forwarded: [(UUID, String)] = []
|
||||
let controller = TerminalSessionController(
|
||||
host: fixture.host, sessionId: nil, environment: fixture.environment,
|
||||
onPendingChanged: { _, _ in },
|
||||
onTitleChanged: { forwarded.append(($0, $1)) }
|
||||
)
|
||||
controller.start()
|
||||
await adopt(controller, transport: fixture.transport, sessionId: adopted)
|
||||
|
||||
// Act: 第一代 VM 上浮。
|
||||
controller.terminalViewModel.setTerminalTitle("gen0")
|
||||
|
||||
// Assert
|
||||
#expect(forwarded.map(\.1) == ["gen0"])
|
||||
|
||||
// Act: 后台→前台重建(新 engine + 新 VM),再上浮一次。
|
||||
controller.suspend()
|
||||
controller.resumeIfNeeded()
|
||||
await adopt(controller, transport: fixture.transport, sessionId: adopted)
|
||||
controller.terminalViewModel.setTerminalTitle("gen1")
|
||||
|
||||
// Assert: 重建没有弄丢 hook,id 仍是 adopted id。
|
||||
#expect(forwarded.map(\.1) == ["gen0", "gen1"])
|
||||
#expect(forwarded.allSatisfy { $0.0 == adopted })
|
||||
controller.teardown()
|
||||
}
|
||||
|
||||
@Test("coordinator:closeTerminal 记 last-seen 水位(切会话/返回列表即已读);title 流入列表行")
|
||||
func coordinatorMarksSeenOnCloseAndRoutesTitles() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeWiringFixture(suiteName: "SessionSwitcherTests.coordinator")
|
||||
let coordinator = AppCoordinator(environment: fixture.environment)
|
||||
let adopted = UUID()
|
||||
await coordinator.sessionList.reloadHosts()
|
||||
|
||||
// Act: 打开新会话 → 服务器 adopted。
|
||||
coordinator.open(SessionListViewModel.OpenRequest(
|
||||
id: UUID(), host: fixture.host, sessionId: nil
|
||||
))
|
||||
let controller = try #require(coordinator.terminalController)
|
||||
await adopt(controller, transport: fixture.transport, sessionId: adopted)
|
||||
|
||||
// Act: OSC 标题上浮(模拟 SwiftTerm delegate)→ 列表行可见。
|
||||
controller.terminalViewModel.setTerminalTitle("vim server.ts")
|
||||
await fixture.http.queueSuccess(
|
||||
url: try listURL(), body: listBody([sessionJSON(id: adopted)])
|
||||
)
|
||||
await coordinator.sessionList.refresh()
|
||||
#expect(coordinator.sessionList.rows.first?.title == "vim server.ts")
|
||||
|
||||
// Act: 返回列表(单活 WS 的切会话前半程:close)。
|
||||
coordinator.closeTerminal()
|
||||
|
||||
// Assert: adopted 会话的 last-seen 已入库 —— 切换回列表即已读。
|
||||
#expect(coordinator.terminalController == nil)
|
||||
#expect(fixture.unreadStore.snapshot[adopted] != nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 测试替身(App 层 UnreadWatermarkStore 的内存实现)
|
||||
|
||||
/// 线程安全的内存水位存储(SessionListViewModelTests 也复用)。
|
||||
final class InMemoryUnreadWatermarkStore: UnreadWatermarkStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var stored: [UUID: Int]
|
||||
|
||||
init(_ initial: [UUID: Int] = [:]) {
|
||||
stored = initial
|
||||
}
|
||||
|
||||
func load() -> [UUID: Int] {
|
||||
lock.withLock { stored }
|
||||
}
|
||||
|
||||
func save(_ watermarks: [UUID: Int]) {
|
||||
lock.withLock { stored = watermarks }
|
||||
}
|
||||
|
||||
var snapshot: [UUID: Int] {
|
||||
lock.withLock { stored }
|
||||
}
|
||||
}
|
||||
434
ios/App/WebTermTests/SessionThumbnailTests.swift
Normal file
434
ios/App/WebTermTests/SessionThumbnailTests.swift
Normal file
@@ -0,0 +1,434 @@
|
||||
import APIClient
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-28 · 会话缩略图(offscreen SwiftTerm)。
|
||||
///
|
||||
/// 覆盖面(Steps 测试先行):
|
||||
/// - 缓存键 (sessionId, lastOutputAt):未变化的会话绝不重渲染;lastOutputAt
|
||||
/// 变化 → 新键重渲染;nil(P1 前旧服务器)→ 渲染一次后永久命中(无失效
|
||||
/// 信号时宁可陈旧也不无界重渲染,文档化降级);
|
||||
/// - 并发上限调度器:离屏渲染受具名常量上限(2)约束,列表滚动绝不无界
|
||||
/// spawn 终端;排队 FIFO、permit 转移;全程零真实等待(continuation 屏障,
|
||||
/// 同 FakeClock.waitForSleepers 手法);
|
||||
/// - 404/错误/校验失败 → placeholder(且同键缓存失败结果,防滚动打爆故障主机);
|
||||
/// - 预览数据是不可信字节:id 不匹配 / data 超限 / 几何出 resizeRange →
|
||||
/// placeholder 且 renderer 不被调用(data 只喂 SwiftTerm,永不字符串处理);
|
||||
/// - 真像素冒烟(仅模拟器):小 ANSI 样例 → 非 nil、非单色快照。
|
||||
@MainActor
|
||||
@Suite("SessionThumbnail")
|
||||
struct SessionThumbnailTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func makeEndpoint() -> HostEndpoint {
|
||||
guard let url = URL(string: "http://192.168.1.20:3000"),
|
||||
let endpoint = HostEndpoint(baseURL: url) else {
|
||||
fatalError("fixture endpoint must be valid")
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
private static func makeRequest(
|
||||
sessionId: UUID = UUID(), lastOutputAt: Int? = 1_000
|
||||
) -> SessionThumbnailRequest {
|
||||
SessionThumbnailRequest(
|
||||
endpoint: makeEndpoint(), sessionId: sessionId, lastOutputAt: lastOutputAt
|
||||
)
|
||||
}
|
||||
|
||||
private static func makePreview(
|
||||
for request: SessionThumbnailRequest, cols: Int = 80, rows: Int = 24,
|
||||
data: String = "hello"
|
||||
) -> SessionPreview {
|
||||
SessionPreview(id: request.sessionId, cols: cols, rows: rows, data: data)
|
||||
}
|
||||
|
||||
/// 主 actor 上的调用计数器(loader/renderer 侧记录)。
|
||||
@MainActor
|
||||
private final class Recorder {
|
||||
private(set) var loaderCalls: [SessionThumbnailRequest] = []
|
||||
private(set) var rendererCalls: [(data: String, cols: Int, rows: Int)] = []
|
||||
func recordLoad(_ request: SessionThumbnailRequest) {
|
||||
loaderCalls = loaderCalls + [request]
|
||||
}
|
||||
func recordRender(data: String, cols: Int, rows: Int) {
|
||||
rendererCalls = rendererCalls + [(data, cols, rows)]
|
||||
}
|
||||
}
|
||||
|
||||
/// 可控放行的 loader 闸(测试决定何时放行;open 后一路直通)。
|
||||
/// 屏障用 continuation,不轮询不真睡(与 FakeClock.waitForSleepers 同手法)。
|
||||
@MainActor
|
||||
private final class LoaderHold {
|
||||
private var held: [CheckedContinuation<Void, Never>] = []
|
||||
private var barriers: [(target: Int, cont: CheckedContinuation<Void, Never>)] = []
|
||||
private var isOpen = false
|
||||
var heldCount: Int { held.count }
|
||||
|
||||
func pass() async {
|
||||
if isOpen { return }
|
||||
await withCheckedContinuation { cont in
|
||||
held = held + [cont]
|
||||
notifyBarriers()
|
||||
}
|
||||
}
|
||||
|
||||
/// 挂起直到至少 target 个 loader 停在闸上。
|
||||
func waitUntilHeld(count target: Int) async {
|
||||
if held.count >= target { return }
|
||||
await withCheckedContinuation { cont in
|
||||
barriers = barriers + [(target, cont)]
|
||||
}
|
||||
}
|
||||
|
||||
/// 放行当前所有被扣住的 loader,并让后续 loader 直通。
|
||||
func open() {
|
||||
isOpen = true
|
||||
let releasing = held
|
||||
held = []
|
||||
for cont in releasing { cont.resume() }
|
||||
}
|
||||
|
||||
private func notifyBarriers() {
|
||||
let met = barriers.filter { $0.target <= held.count }
|
||||
barriers = barriers.filter { $0.target > held.count }
|
||||
for barrier in met { barrier.cont.resume() }
|
||||
}
|
||||
}
|
||||
|
||||
private static func makePipeline(
|
||||
recorder: Recorder,
|
||||
gate: SessionThumbnailRenderGate = SessionThumbnailRenderGate(
|
||||
limit: SessionThumbnailTunable.maxConcurrentRenders
|
||||
),
|
||||
maxCacheEntries: Int = SessionThumbnailTunable.maxCacheEntries,
|
||||
hold: LoaderHold? = nil,
|
||||
loaderError: (any Error)? = nil,
|
||||
preview: ((SessionThumbnailRequest) -> SessionPreview)? = nil,
|
||||
rendered: UIImage? = UIImage()
|
||||
) -> SessionThumbnailPipeline {
|
||||
SessionThumbnailPipeline(
|
||||
loader: { request in
|
||||
if let hold { await hold.pass() }
|
||||
recorder.recordLoad(request)
|
||||
if let loaderError { throw loaderError }
|
||||
return preview?(request) ?? Self.makePreview(for: request)
|
||||
},
|
||||
renderer: { data, cols, rows in
|
||||
recorder.recordRender(data: data, cols: cols, rows: rows)
|
||||
return rendered
|
||||
},
|
||||
gate: gate,
|
||||
maxCacheEntries: maxCacheEntries
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 缓存键 (sessionId, lastOutputAt)
|
||||
|
||||
@Test("同键第二次请求命中缓存——loader 只调一次,返回同一图像")
|
||||
func sameKeyHitsCache() async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(recorder: recorder)
|
||||
let request = Self.makeRequest()
|
||||
|
||||
let first = await pipeline.thumbnail(for: request)
|
||||
let second = await pipeline.thumbnail(for: request)
|
||||
|
||||
#expect(recorder.loaderCalls.count == 1)
|
||||
#expect(first == second)
|
||||
#expect(!first.isPlaceholder)
|
||||
}
|
||||
|
||||
@Test("lastOutputAt 变化(会话有新输出)→ 新键重新渲染")
|
||||
func newerLastOutputAtRerenders() async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(recorder: recorder)
|
||||
let sessionId = UUID()
|
||||
|
||||
_ = await pipeline.thumbnail(for: Self.makeRequest(sessionId: sessionId, lastOutputAt: 1_000))
|
||||
_ = await pipeline.thumbnail(for: Self.makeRequest(sessionId: sessionId, lastOutputAt: 2_000))
|
||||
|
||||
#expect(recorder.loaderCalls.count == 2)
|
||||
}
|
||||
|
||||
@Test("lastOutputAt 为 nil(P1 前服务器)→ 渲染一次后命中缓存(文档化降级)")
|
||||
func nilLastOutputAtCachesOnce() async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(recorder: recorder)
|
||||
let request = Self.makeRequest(lastOutputAt: nil)
|
||||
|
||||
_ = await pipeline.thumbnail(for: request)
|
||||
_ = await pipeline.thumbnail(for: request)
|
||||
|
||||
#expect(recorder.loaderCalls.count == 1)
|
||||
}
|
||||
|
||||
@Test("并发去重:同键两个并发请求 → loader 一次、两个调用者拿到同一结果")
|
||||
func concurrentSameKeyDeduplicates() async {
|
||||
let recorder = Recorder()
|
||||
let hold = LoaderHold()
|
||||
let pipeline = Self.makePipeline(recorder: recorder, hold: hold)
|
||||
let request = Self.makeRequest()
|
||||
|
||||
let taskA = Task { await pipeline.thumbnail(for: request) }
|
||||
let taskB = Task { await pipeline.thumbnail(for: request) }
|
||||
await hold.waitUntilHeld(count: 1) // 只有一个 loader 在飞
|
||||
hold.open()
|
||||
let a = await taskA.value
|
||||
let b = await taskB.value
|
||||
|
||||
#expect(recorder.loaderCalls.count == 1)
|
||||
#expect(a == b)
|
||||
}
|
||||
|
||||
// MARK: - 并发上限调度器(滚动不得无界 spawn 离屏终端)
|
||||
|
||||
@Test("5 个不同键并发:同时在渲染管线内的至多 maxConcurrentRenders(2),其余排队")
|
||||
func concurrencyCapHoldsUnderBurst() async {
|
||||
let recorder = Recorder()
|
||||
let gate = SessionThumbnailRenderGate(limit: SessionThumbnailTunable.maxConcurrentRenders)
|
||||
let hold = LoaderHold()
|
||||
let pipeline = Self.makePipeline(recorder: recorder, gate: gate, hold: hold)
|
||||
let requests = (0..<5).map { _ in Self.makeRequest() }
|
||||
|
||||
let tasks = requests.map { request in
|
||||
Task { await pipeline.thumbnail(for: request) }
|
||||
}
|
||||
await hold.waitUntilHeld(count: 2) // 两个已过闸进入 loader
|
||||
await gate.waitUntilWaiting(count: 3) // 其余三个停在渲染闸上
|
||||
|
||||
#expect(gate.activeCount == 2)
|
||||
#expect(gate.peakActiveCount == 2)
|
||||
#expect(hold.heldCount == 2)
|
||||
|
||||
hold.open()
|
||||
for task in tasks { _ = await task.value }
|
||||
|
||||
#expect(gate.peakActiveCount == SessionThumbnailTunable.maxConcurrentRenders)
|
||||
#expect(recorder.loaderCalls.count == 5)
|
||||
#expect(gate.activeCount == 0)
|
||||
}
|
||||
|
||||
@Test("gate FIFO:permit 按排队顺序转移")
|
||||
func gateIsFifo() async {
|
||||
let gate = SessionThumbnailRenderGate(limit: 1)
|
||||
await gate.acquire()
|
||||
let order = OrderBox()
|
||||
|
||||
let third = Task {
|
||||
await gate.acquire()
|
||||
order.append(3)
|
||||
gate.release()
|
||||
}
|
||||
await gate.waitUntilWaiting(count: 1)
|
||||
let fourth = Task {
|
||||
await gate.acquire()
|
||||
order.append(4)
|
||||
gate.release()
|
||||
}
|
||||
await gate.waitUntilWaiting(count: 2)
|
||||
|
||||
gate.release() // permit 依次传给 3 再 4
|
||||
_ = await third.value
|
||||
_ = await fourth.value
|
||||
|
||||
#expect(order.values == [3, 4])
|
||||
#expect(gate.activeCount == 0)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OrderBox {
|
||||
private(set) var values: [Int] = []
|
||||
func append(_ value: Int) { values = values + [value] }
|
||||
}
|
||||
|
||||
// MARK: - placeholder 路径(404/错误/校验失败)
|
||||
|
||||
@Test("loader 抛错(如 404 sessionNotFound)→ placeholder,且失败同键缓存不重试")
|
||||
func loaderFailureYieldsCachedPlaceholder() async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(
|
||||
recorder: recorder, loaderError: APIClientError.sessionNotFound
|
||||
)
|
||||
let request = Self.makeRequest()
|
||||
|
||||
let first = await pipeline.thumbnail(for: request)
|
||||
let second = await pipeline.thumbnail(for: request)
|
||||
|
||||
#expect(first.isPlaceholder)
|
||||
#expect(second.isPlaceholder)
|
||||
#expect(recorder.loaderCalls.count == 1) // 失败也按键缓存
|
||||
#expect(recorder.rendererCalls.isEmpty)
|
||||
}
|
||||
|
||||
@Test("renderer 返回 nil(快照失败)→ placeholder")
|
||||
func rendererNilYieldsPlaceholder() async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(recorder: recorder, rendered: nil)
|
||||
|
||||
let result = await pipeline.thumbnail(for: Self.makeRequest())
|
||||
|
||||
#expect(result.isPlaceholder)
|
||||
#expect(recorder.rendererCalls.count == 1)
|
||||
}
|
||||
|
||||
// MARK: - 不可信服务器数据(renderer 不被调用即为「拒于边界」)
|
||||
|
||||
@Test("preview.id 与请求的 sessionId 不匹配 → placeholder,renderer 不调")
|
||||
func mismatchedPreviewIdRejected() async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(recorder: recorder, preview: { _ in
|
||||
SessionPreview(id: UUID(), cols: 80, rows: 24, data: "x") // id ≠ request.sessionId
|
||||
})
|
||||
|
||||
let result = await pipeline.thumbnail(for: Self.makeRequest())
|
||||
|
||||
#expect(result.isPlaceholder)
|
||||
#expect(recorder.rendererCalls.isEmpty)
|
||||
}
|
||||
|
||||
@Test("data 超出 maxPreviewDataBytes → placeholder,renderer 不调")
|
||||
func oversizedPreviewDataRejected() async {
|
||||
let recorder = Recorder()
|
||||
let oversized = String(
|
||||
repeating: "a", count: SessionThumbnailTunable.maxPreviewDataBytes + 1
|
||||
)
|
||||
let pipeline = Self.makePipeline(recorder: recorder, preview: { request in
|
||||
Self.makePreview(for: request, data: oversized)
|
||||
})
|
||||
|
||||
let result = await pipeline.thumbnail(for: Self.makeRequest())
|
||||
|
||||
#expect(result.isPlaceholder)
|
||||
#expect(recorder.rendererCalls.isEmpty)
|
||||
}
|
||||
|
||||
@Test("几何出服务器 resizeRange(0 / 1001)→ placeholder,renderer 不调", arguments: [
|
||||
(0, 24), (80, 0), (1_001, 24), (80, 1_001),
|
||||
])
|
||||
func outOfRangeGeometryRejected(cols: Int, rows: Int) async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(recorder: recorder, preview: { request in
|
||||
Self.makePreview(for: request, cols: cols, rows: rows)
|
||||
})
|
||||
|
||||
let result = await pipeline.thumbnail(for: Self.makeRequest())
|
||||
|
||||
#expect(result.isPlaceholder)
|
||||
#expect(recorder.rendererCalls.isEmpty)
|
||||
}
|
||||
|
||||
@Test("几何钳制:合法值透传;1→2(镜像 web max(2,·));大值钳到缩略图上限")
|
||||
func geometryClamping() {
|
||||
let normal = SessionThumbnailPipeline.clampedGeometry(cols: 80, rows: 24)
|
||||
#expect(normal?.cols == 80 && normal?.rows == 24)
|
||||
|
||||
let tiny = SessionThumbnailPipeline.clampedGeometry(cols: 1, rows: 1)
|
||||
#expect(tiny?.cols == SessionThumbnailTunable.minRenderGrid)
|
||||
#expect(tiny?.rows == SessionThumbnailTunable.minRenderGrid)
|
||||
|
||||
let huge = SessionThumbnailPipeline.clampedGeometry(cols: 1_000, rows: 1_000)
|
||||
#expect(huge?.cols == SessionThumbnailTunable.maxRenderCols)
|
||||
#expect(huge?.rows == SessionThumbnailTunable.maxRenderRows)
|
||||
|
||||
#expect(SessionThumbnailPipeline.clampedGeometry(cols: 0, rows: 24) == nil)
|
||||
#expect(SessionThumbnailPipeline.clampedGeometry(cols: 80, rows: 1_001) == nil)
|
||||
}
|
||||
|
||||
@Test("渲染几何真被钳制后才交给 renderer")
|
||||
func rendererReceivesClampedGeometry() async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(recorder: recorder, preview: { request in
|
||||
Self.makePreview(for: request, cols: 1_000, rows: 1)
|
||||
})
|
||||
|
||||
_ = await pipeline.thumbnail(for: Self.makeRequest())
|
||||
|
||||
#expect(recorder.rendererCalls.count == 1)
|
||||
#expect(recorder.rendererCalls.first?.cols == SessionThumbnailTunable.maxRenderCols)
|
||||
#expect(recorder.rendererCalls.first?.rows == SessionThumbnailTunable.minRenderGrid)
|
||||
}
|
||||
|
||||
// MARK: - LRU 缓存
|
||||
|
||||
@Test("LRU:容量 3 时命中会前移——插 A,B,C、命中 A、插 D → 逐出 B,A/C/D 仍在")
|
||||
func lruEvictsLeastRecentlyUsed() async {
|
||||
let recorder = Recorder()
|
||||
let pipeline = Self.makePipeline(recorder: recorder, maxCacheEntries: 3)
|
||||
let a = Self.makeRequest()
|
||||
let b = Self.makeRequest()
|
||||
let c = Self.makeRequest()
|
||||
let d = Self.makeRequest()
|
||||
|
||||
_ = await pipeline.thumbnail(for: a)
|
||||
_ = await pipeline.thumbnail(for: b)
|
||||
_ = await pipeline.thumbnail(for: c)
|
||||
_ = await pipeline.thumbnail(for: a) // bump A → LRU 现在是 B
|
||||
_ = await pipeline.thumbnail(for: d) // 逐出 B
|
||||
#expect(recorder.loaderCalls.count == 4)
|
||||
|
||||
_ = await pipeline.thumbnail(for: b) // B 已被逐出 → 重新加载
|
||||
#expect(recorder.loaderCalls.count == 5)
|
||||
|
||||
_ = await pipeline.thumbnail(for: a) // C 被 B 的回插逐出也无妨——A 仍应在
|
||||
#expect(recorder.loaderCalls.count == 5)
|
||||
}
|
||||
|
||||
// MARK: - 常量与文案
|
||||
|
||||
@Test("具名常量在位:并发上限 2、缓存上限 > 0、数据上限覆盖服务器默认 24KB")
|
||||
func tunablesArePinned() {
|
||||
#expect(SessionThumbnailTunable.maxConcurrentRenders == 2)
|
||||
#expect(SessionThumbnailTunable.maxCacheEntries > 0)
|
||||
// 服务器默认 previewBytes = 24*1024(src/config.ts:46,env 可调)——
|
||||
// 客户端防御上限必须至少覆盖默认值。
|
||||
#expect(SessionThumbnailTunable.maxPreviewDataBytes >= 24 * 1024)
|
||||
#expect(SessionThumbnailTunable.minRenderGrid == 2)
|
||||
}
|
||||
|
||||
@Test("用户可见文案:中文具名常量、非空")
|
||||
func copyIsNonEmptyChinese() {
|
||||
#expect(!SessionThumbnailCopy.thumbnailLabel.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - 像素冒烟(仅模拟器;真 SwiftTerm 离屏渲染)
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
@Test("离屏渲染小 ANSI 样例 → 非 nil、宽>高、非单色、宽度受快照预算约束")
|
||||
func rendererSmokeProducesNonBlankImage() throws {
|
||||
let sample = "\u{1b}[31mRED\u{1b}[0m plain \u{1b}[44m BLUE-BG \u{1b}[0m"
|
||||
let image = SessionThumbnailRenderer.render(data: sample, cols: 20, rows: 5)
|
||||
|
||||
let snapshot = try #require(image)
|
||||
#expect(snapshot.size.width > 0 && snapshot.size.height > 0)
|
||||
#expect(snapshot.size.width > snapshot.size.height) // 20×5 网格是横幅
|
||||
#expect(snapshot.size.width <= SessionThumbnailRenderer.snapshotTargetWidth + 1)
|
||||
#expect(Self.hasMultipleColors(snapshot)) // 空白/纯色 = 渲染管线断了
|
||||
}
|
||||
|
||||
/// 把快照解到 RGBA 缓冲里扫描:出现 ≥2 种像素值即「画上东西了」。
|
||||
private static func hasMultipleColors(_ image: UIImage) -> Bool {
|
||||
guard let cgImage = image.cgImage else { return false }
|
||||
let width = min(cgImage.width, 64)
|
||||
let height = min(cgImage.height, 64)
|
||||
let bytesPerPixel = 4
|
||||
var buffer = [UInt8](repeating: 0, count: width * height * bytesPerPixel)
|
||||
guard let context = CGContext(
|
||||
data: &buffer, width: width, height: height, bitsPerComponent: 8,
|
||||
bytesPerRow: width * bytesPerPixel,
|
||||
space: CGColorSpaceCreateDeviceRGB(),
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else { return false }
|
||||
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
let first = Array(buffer.prefix(bytesPerPixel))
|
||||
for pixel in stride(from: 0, to: buffer.count, by: bytesPerPixel)
|
||||
where Array(buffer[pixel..<(pixel + bytesPerPixel)]) != first {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
197
ios/App/WebTermTests/TimelineSheetTests.swift
Normal file
197
ios/App/WebTermTests/TimelineSheetTests.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-24 · Timeline sheet(完整时间线钻取)。
|
||||
///
|
||||
/// 覆盖面(Steps 测试先行):
|
||||
/// - `/live-sessions/:id/events` 全量渲染管线:呈现顺序**逐行镜像 web**
|
||||
/// (public/timeline.ts:174-189 render()——`slice(0, maxEvents)` 先截断
|
||||
/// 再 reverse,newest-first 展示);
|
||||
/// - class → 图标映射逐字镜像 web `timelineIcon`(public/timeline.ts:88-96),
|
||||
/// 颜色为语义映射(web CSS 未定义 tl-icon-* 颜色——iOS 对齐
|
||||
/// SessionListScreen 的状态色约定);未知 class 走 fallback(防御纵深,
|
||||
/// 服务器是不可信输入源——即使 APIClient.decodeList 已丢弃未知类);
|
||||
/// - timeline disabled(服务器回 `[]`,src/server.ts:589-591)→ 空态而非错误;
|
||||
/// - fetch 失败 → 显式可重试错误态(retry 后可达 .loaded);
|
||||
/// - digest「展开」入口的装配缝:`forSession` 工厂(sessionId 未知 → 不出
|
||||
/// sheet;已知 → 原样传给 events source)。
|
||||
@MainActor
|
||||
@Suite("TimelineSheet")
|
||||
struct TimelineSheetTests {
|
||||
// MARK: - Fixtures
|
||||
|
||||
private static func event(
|
||||
at: Int, cls: String = "tool", label: String = "ran Bash"
|
||||
) -> TimelineEvent {
|
||||
TimelineEvent(at: at, class: cls, toolName: nil, label: label)
|
||||
}
|
||||
|
||||
private struct FetchError: Error {}
|
||||
|
||||
/// 记录 source 收到的 sessionId(@Sendable 闭包内可变状态 → actor)。
|
||||
private actor SourceRecorder {
|
||||
private(set) var ids: [UUID] = []
|
||||
func record(_ id: UUID) { ids = ids + [id] }
|
||||
}
|
||||
|
||||
// MARK: - Phase 状态机
|
||||
|
||||
@Test("初始 phase = .loading(load 前不渲染内容)")
|
||||
func initialPhaseIsLoading() {
|
||||
let vm = TimelineViewModel(fetch: { [] })
|
||||
|
||||
#expect(vm.phase == .loading)
|
||||
}
|
||||
|
||||
@Test("load 成功:服务器 oldest-first → 展示 newest-first(镜像 web reverse)")
|
||||
func loadReversesToNewestFirst() async {
|
||||
let oldestFirst = [
|
||||
Self.event(at: 1, label: "ran Bash"),
|
||||
Self.event(at: 2, cls: "waiting", label: "waiting for approval"),
|
||||
Self.event(at: 3, cls: "done", label: "finished"),
|
||||
]
|
||||
let vm = TimelineViewModel(fetch: { oldestFirst })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .loaded(Array(oldestFirst.reversed())))
|
||||
}
|
||||
|
||||
@Test("超过 maxEvents:镜像 web slice(0,50) 再 reverse——先截前 50 条再反转")
|
||||
func loadCapsThenReversesLikeWeb() async throws {
|
||||
let sixty = (1...60).map { Self.event(at: $0) }
|
||||
let vm = TimelineViewModel(fetch: { sixty })
|
||||
|
||||
await vm.load()
|
||||
|
||||
guard case .loaded(let shown) = vm.phase else {
|
||||
Issue.record("期望 .loaded,实际 \(vm.phase)")
|
||||
return
|
||||
}
|
||||
#expect(shown.count == TimelineViewModel.maxEvents)
|
||||
#expect(shown.first?.at == 50) // slice(0,50) 的最新一条
|
||||
#expect(shown.last?.at == 1) // 反转后最旧的在末尾
|
||||
}
|
||||
|
||||
@Test("maxEvents 钉住 web parity 值 50(public/timeline.ts:20)")
|
||||
func maxEventsMirrorsWebDefault() {
|
||||
#expect(TimelineViewModel.maxEvents == 50)
|
||||
}
|
||||
|
||||
@Test("服务器回空数组(timeline disabled)→ .empty,绝不是 .failed")
|
||||
func emptyArrayIsEmptyStateNotError() async {
|
||||
let vm = TimelineViewModel(fetch: { [] })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .empty)
|
||||
}
|
||||
|
||||
@Test("fetch 抛错 → .failed(显式可重试错误态)")
|
||||
func fetchFailureBecomesFailedPhase() async {
|
||||
let vm = TimelineViewModel(fetch: { throw FetchError() })
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(vm.phase == .failed)
|
||||
}
|
||||
|
||||
@Test("重试:失败后再次 load 成功 → .loaded(错误态可恢复)")
|
||||
func retryAfterFailureRecovers() async {
|
||||
let flag = SourceRecorder() // 复用 actor 当计数器:首轮抛错,次轮成功
|
||||
let events = [Self.event(at: 7)]
|
||||
let vm = TimelineViewModel(fetch: {
|
||||
if await flag.ids.isEmpty {
|
||||
await flag.record(UUID())
|
||||
throw FetchError()
|
||||
}
|
||||
return events
|
||||
})
|
||||
|
||||
await vm.load()
|
||||
#expect(vm.phase == .failed)
|
||||
|
||||
await vm.load() // TimelineSheet 的「重试」按钮走的就是这条路径
|
||||
|
||||
#expect(vm.phase == .loaded(events))
|
||||
}
|
||||
|
||||
// MARK: - digest「展开」入口的装配缝(forSession 工厂)
|
||||
|
||||
@Test("forSession(nil):会话尚未 adopted → 不构造 VM(无 sheet 可出)")
|
||||
func forSessionWithoutIdReturnsNil() {
|
||||
let vm = TimelineViewModel.forSession(nil, source: { _ in [] })
|
||||
|
||||
#expect(vm == nil)
|
||||
}
|
||||
|
||||
@Test("forSession(id):load 把 sessionId 原样传给 events source")
|
||||
func forSessionPassesSessionIdThrough() async throws {
|
||||
let expected = UUID()
|
||||
let recorder = SourceRecorder()
|
||||
let events = [Self.event(at: 42)]
|
||||
let vm = try #require(TimelineViewModel.forSession(expected, source: { id in
|
||||
await recorder.record(id)
|
||||
return events
|
||||
}))
|
||||
|
||||
await vm.load()
|
||||
|
||||
#expect(await recorder.ids == [expected])
|
||||
#expect(vm.phase == .loaded(events))
|
||||
}
|
||||
|
||||
// MARK: - class → 图标 / 颜色映射
|
||||
|
||||
@Test("图标逐字镜像 web timelineIcon(public/timeline.ts:88-96)", arguments: [
|
||||
("tool", "🔧"),
|
||||
("waiting", "⏳"),
|
||||
("done", "✓"),
|
||||
("stuck", "⚠"),
|
||||
("user", "💬"),
|
||||
])
|
||||
func glyphMirrorsWebTimelineIcon(cls: String, expected: String) {
|
||||
#expect(TimelineClassStyle.glyph(for: cls) == expected)
|
||||
}
|
||||
|
||||
@Test("未知 class → fallback 图标(服务器不可信,映射必须全函数)")
|
||||
func unknownClassFallsBackToNeutralGlyph() {
|
||||
#expect(TimelineClassStyle.glyph(for: "future-class") == TimelineClassStyle.fallbackGlyph)
|
||||
#expect(TimelineClassStyle.glyph(for: "") == TimelineClassStyle.fallbackGlyph)
|
||||
}
|
||||
|
||||
@Test("颜色语义映射:对齐 SessionListScreen 状态色约定;未知 → secondary")
|
||||
func colorMappingIsSemantic() {
|
||||
#expect(TimelineClassStyle.color(for: "waiting") == .orange) // 列表 waiting 同色
|
||||
#expect(TimelineClassStyle.color(for: "stuck") == .red) // 列表 stuck 同色
|
||||
#expect(TimelineClassStyle.color(for: "done") == .green)
|
||||
#expect(TimelineClassStyle.color(for: "tool") == .blue)
|
||||
#expect(TimelineClassStyle.color(for: "user") == .purple)
|
||||
#expect(TimelineClassStyle.color(for: "future-class") == .secondary)
|
||||
}
|
||||
|
||||
// MARK: - 行时间格式(镜像 web formatHHMM:24h 墙钟 HH:mm)
|
||||
|
||||
@Test("timeLabel:24h HH:mm,固定时区下确定性输出")
|
||||
func timeLabelIs24HourWallClock() throws {
|
||||
let utc = try #require(TimeZone(identifier: "UTC"))
|
||||
let onePM = (13 * 3_600 + 5 * 60) * 1_000 // 1970-01-01 13:05 UTC
|
||||
|
||||
#expect(TimelineRowFormat.timeLabel(atMs: 0, timeZone: utc) == "00:00")
|
||||
#expect(TimelineRowFormat.timeLabel(atMs: onePM, timeZone: utc) == "13:05")
|
||||
}
|
||||
|
||||
// MARK: - 用户可见文案(中文具名常量,空态 ≠ 错误态)
|
||||
|
||||
@Test("空态与错误态文案存在、非空、且互不相同")
|
||||
func emptyAndErrorCopyAreDistinct() {
|
||||
#expect(!TimelineCopy.title.isEmpty)
|
||||
#expect(!TimelineCopy.emptyTitle.isEmpty)
|
||||
#expect(!TimelineCopy.loadFailed.isEmpty)
|
||||
#expect(!TimelineCopy.retry.isEmpty)
|
||||
#expect(TimelineCopy.emptyTitle != TimelineCopy.loadFailed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user