feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push

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

View File

@@ -0,0 +1,338 @@
import Foundation
import HostRegistry
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-22 · Deep-link routing: `webterminal://open?host=<uuid>&join=<uuid>`
/// .ignoreWEBTERM_GATE push
/// payload DeepLinkHandler stash / host
/// /
@MainActor
@Suite("DeepLinkRouter")
struct DeepLinkRouterTests {
// MARK: - Fixtures
/// v4 UUIDversion nibble=4variant=8 Validation.isValidSessionId
private static let validHostId = "11111111-2222-4333-8444-555555555555"
private static let validSessionId = "0f5a1f2e-3b4c-4d5e-8f6a-7b8c9d0e1f2a"
/// version nibble=1Foundation UUID(uuidString:)
/// SESSION_ID_REv4 " 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 非 v4version 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 payloadWEBTERM_GATET-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 idUUID 合法但不在 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)
}
}

View 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 requireAllowedOriginsrc/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 .pathInvalid404 .projectNotFoundSEC-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无 OriginRO 端点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)
}
}
}

View 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 returnrenamed
@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 → .emptytruncated 数据位透传)", 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 = truebanner 提示位)")
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("untracked0 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/非法请求 → pathInvalid404 → 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) == "删除")
}
}

View 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
/// closeopen `attach(null, cwd)` web tabs.ts
/// `newTab()` M6 cwdcwd /live-sessions
/// server-adopted id controller spawnCwdT-iOS-26
/// fresh-spawn
/// ProjectsViewModel
/// 2. exited + exit TerminalViewModel P0 +
/// ""src/session/manager.ts:145-153exited
/// 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: - FixturesSessionSwitcherTests 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 adoptedopenTask 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()
// Assertcloseopen 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.spawnCwdbootstrap 绝不复注入")
func newInCwdFallsBackToSpawnCwdWithoutBootstrap() async throws {
// Arrange fresh spawnspawnCwd + 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-153reap
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)
// Assertexit
#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")))
}
}

View 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.handleAllow/Deny POST
/// /hook/decision403
/// 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("403token 过期/已用)→ 本地通知兜底提示进 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("无主机可配 → 不打开、不 crashApp 已被点按拉起,落在列表页即可)")
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)
}
}

View 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 → .dismissedno-op")
func parseDismissAndUnknownActions() {
#expect(NotificationActionHandler.parse(
actionIdentifier: UNNotificationDismissActionIdentifier,
userInfo: Self.gateUserInfo()
) == .dismissed)
#expect(NotificationActionHandler.parse(
actionIdentifier: "EVIL_ACTION", userInfo: Self.gateUserInfo()
) == .dismissed)
}
}

View 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("初始 .loadingload 成功 → .loadedsessions/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
}
}

View 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("filtername 与 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: - sortfavourites 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 groupProjectsother noNamespace
// namespacepublic/projects.ts:163-166 plain
#expect(groups[1].projects == [plain, solo])
}
@Test("groupnamespace 桶大小写不敏感合并,显示名取首见大小写")
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("groupnamespace 组按组内最新 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("displayLabelnamespace 组内剥前缀(大小写不敏感);哨兵组保留全名")
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])
}
}

View 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()
}
}

View 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不再打 /prefsprefs 留在内存,镜像 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("toggleFavouritePUT 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展开只删自己的 keyfavourites 与未知键原样保留")
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)
}
}

View 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: - Fakesseam 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 hexAPNs
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)
}
}

View File

@@ -0,0 +1,393 @@
import Foundation
import SessionCore
import TestSupport
import Testing
import WireProtocol
@testable import WebTerm
/// T-iOS-25 · Quick-reply chips +
///
/// Steps
/// - chips web `public/quick-reply.ts` `BUILT_IN_CHIPS`
/// id/text/label/appendEnter Esc `KeyByteMap`
/// App
/// - chip `input` + `\r`Enter 0x0D 0x0A
/// TerminalViewModel =
/// - CRUD + add/remove/reorder/update
/// ChipUserDefaults suite Keychain
/// / crash
/// - waitingSessionEvent gatestatus waiting
/// pending=true gate exited/failedread-only
///
@MainActor
@Suite("QuickReply")
struct QuickReplyTests {
// MARK: - Fixtures
private enum ServerFrames {
static func attached(_ id: UUID) -> String {
#"{"type":"attached","sessionId":"\#(id.uuidString.lowercased())"}"#
}
static func status(pending: Bool, gate: String? = nil) -> String {
var fields = ["\"type\":\"status\"", "\"status\":\"waiting\"", "\"pending\":\(pending)"]
if let gate { fields.append("\"gate\":\"\(gate)\"") }
return "{\(fields.joined(separator: ","))}"
}
static func exit(code: Int) -> String {
#"{"type":"exit","code":\#(code)}"#
}
}
private static func chip(
id: String = "user_t", text: String = "yes", label: String = "yes",
appendEnter: Bool = true
) -> QuickReplyChip {
QuickReplyChip(id: id, text: text, label: label, appendEnter: appendEnter)
}
/// UserDefaults suite LastSessionStoreTests
private static func makeSuiteName() -> String {
"quick-reply-tests-\(UUID().uuidString)"
}
@MainActor
private final class NoopHaptics: HapticSignaling {
func gateDidArrive() {}
}
// MARK: - Harness engine + fan-out 线
@MainActor
private final class Harness {
let transport = FakeTransport()
let clock = FakeClock()
let engine: SessionEngine
let fanOut: EventFanOut<SessionEvent>
let terminalViewModel: TerminalViewModel
let gateViewModel: GateViewModel
init() throws {
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: { _ in [] }
)
fanOut = EventFanOut(source: engine.events, branchCount: 2)
terminalViewModel = TerminalViewModel(engine: engine, events: fanOut.branches[0])
gateViewModel = GateViewModel(
engine: engine, events: fanOut.branches[1],
haptics: NoopHaptics(), clock: clock
)
terminalViewModel.start()
gateViewModel.start()
}
/// open .connecting .connected attached3 VM
func openAndAdopt(_ id: UUID = UUID()) async {
await engine.open(sessionId: nil, cwd: nil)
await transport.emit(frame: ServerFrames.attached(id))
await waitBothProcessed(eventCount: 3)
}
/// fan-out VM
func waitBothProcessed(eventCount: Int) async {
await terminalViewModel.waitUntilProcessed(eventCount: eventCount)
await gateViewModel.waitUntilProcessed(eventCount: eventCount)
}
var isBarVisible: Bool {
QuickReplyBar.isVisible(
gate: gateViewModel.currentGate,
isReadOnly: terminalViewModel.isReadOnly
)
}
/// 0 attach input
func wireFrames() async -> [String] {
let byConnection = await transport.sentFramesByConnection
return byConnection.first ?? []
}
}
// MARK: - chips public/quick-reply.ts BUILT_IN_CHIPS
@Test("内置 chips 逐项镜像 webid/text/label/appendEnter 与顺序完全一致")
func builtInChipsMirrorWebDefaults() {
let chips = QuickReplyPalette.builtInChips
#expect(chips.map(\.id) == ["__yes", "__continue", "__1", "__2", "__3", "__esc"])
#expect(chips.map(\.text) == [
"yes", "continue", "1", "2", "3", KeyByteMap.bytes(for: .esc),
])
#expect(chips.map(\.label) == ["yes", "continue", "1", "2", "3", "Esc"])
#expect(chips.map(\.appendEnter) == [true, true, true, true, true, false])
}
@Test("payloadappendEnter → 文本 + Enter\\r0x0D——CLAUDE.md gotcha否则原文")
func payloadComposition() {
let enter = Self.chip(text: "continue", appendEnter: true)
#expect(QuickReplyPalette.payload(for: enter)
== "continue" + KeyByteMap.bytes(for: .enter))
#expect(QuickReplyPalette.payload(for: enter) == "continue\r")
let bare = Self.chip(text: KeyByteMap.bytes(for: .esc), appendEnter: false)
#expect(QuickReplyPalette.payload(for: bare) == KeyByteMap.bytes(for: .esc))
}
// MARK: - CRUD addChip/removeChip/reorderChip/updateChip
@Test("adding 追加到末尾removing 按 id 过滤;未知 id 原样")
func addingAndRemoving() {
let a = Self.chip(id: "user_a")
let b = Self.chip(id: "user_b")
let added = QuickReplyPalette.adding([a], b)
#expect(added == [a, b])
#expect(QuickReplyPalette.removing(added, id: "user_a") == [b])
#expect(QuickReplyPalette.removing(added, id: "user_zzz") == [a, b])
}
@Test("reordering前移/后移语义同 web splice任一索引越界 → 原样返回")
func reorderingMovesAndBoundsChecks() {
let a = Self.chip(id: "user_a")
let b = Self.chip(id: "user_b")
let c = Self.chip(id: "user_c")
let chips = [a, b, c]
#expect(QuickReplyPalette.reordering(chips, fromIndex: 0, toIndex: 2) == [b, c, a])
#expect(QuickReplyPalette.reordering(chips, fromIndex: 2, toIndex: 0) == [c, a, b])
#expect(QuickReplyPalette.reordering(chips, fromIndex: 3, toIndex: 0) == chips)
#expect(QuickReplyPalette.reordering(chips, fromIndex: 0, toIndex: -1) == chips)
}
@Test("updating按 id 浅合并 patchid 不可变,其余 chip 不受影响;未知 id 原样")
func updatingPatchesMatchingChipOnly() {
let a = Self.chip(id: "user_a", text: "yes", label: "yes", appendEnter: true)
let b = Self.chip(id: "user_b", text: "go", label: "Go", appendEnter: false)
let updated = QuickReplyPalette.updating(
[a, b], id: "user_a", text: "no", label: "No"
)
#expect(updated == [
QuickReplyChip(id: "user_a", text: "no", label: "No", appendEnter: true),
b,
])
#expect(QuickReplyPalette.updating([a, b], id: "user_zzz", text: "x") == [a, b])
}
// MARK: - StoreUserDefaults suite
@Test("addChip修剪空白、label 空则取 text、id 带 user_ 前缀且唯一;跨实例持久")
func addPersistsAcrossInstances() throws {
let suiteName = Self.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = QuickReplyStore(defaults: defaults)
#expect(store.addChip(text: " 跑测试 ", label: " ", appendEnter: true))
#expect(store.addChip(text: "git status", label: "状态", appendEnter: false))
let chips = store.userChips
#expect(chips.map(\.text) == ["跑测试", "git status"])
#expect(chips.map(\.label) == ["跑测试", "状态"])
#expect(chips.map(\.appendEnter) == [true, false])
#expect(chips.allSatisfy { $0.id.hasPrefix(QuickReplyPalette.userChipIdPrefix) })
#expect(chips[0].id != chips[1].id)
// chips = + web render
#expect(store.allChips == QuickReplyPalette.builtInChips + chips)
// suite
let reloaded = QuickReplyStore(defaults: defaults)
#expect(reloaded.userChips == chips)
}
@Test("addChip空文本含纯空白是 no-op返回 false 且不持久化")
func emptyTextAddIsNoOp() throws {
let suiteName = Self.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = QuickReplyStore(defaults: defaults)
#expect(!store.addChip(text: " ", label: "标签", appendEnter: true))
#expect(store.userChips.isEmpty)
#expect(QuickReplyStore(defaults: defaults).userChips.isEmpty)
}
@Test("remove/update/reorder 均持久化:新实例读回操作后的面板")
func mutationsPersistAcrossInstances() throws {
let suiteName = Self.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = QuickReplyStore(defaults: defaults)
#expect(store.addChip(text: "a", label: "A", appendEnter: true))
#expect(store.addChip(text: "b", label: "B", appendEnter: true))
#expect(store.addChip(text: "c", label: "C", appendEnter: true))
let ids = store.userChips.map(\.id)
// reorder: A,B,C B,C,Aupdate B labelremove C
store.moveChip(fromIndex: 0, toIndex: 2)
store.updateChip(id: ids[1], label: "B改")
store.removeChip(id: ids[2])
let expected = [
QuickReplyChip(id: ids[1], text: "b", label: "B改", appendEnter: true),
QuickReplyChip(id: ids[0], text: "a", label: "A", appendEnter: true),
]
#expect(store.userChips == expected)
#expect(QuickReplyStore(defaults: defaults).userChips == expected)
}
@Test("SwiftUI onMove 适配IndexSet + 目的地约定)→ 与纯 reordering 同结果")
func onMoveAdapterMatchesReorderingSemantics() throws {
let suiteName = Self.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = QuickReplyStore(defaults: defaults)
#expect(store.addChip(text: "a", label: "A", appendEnter: true))
#expect(store.addChip(text: "b", label: "B", appendEnter: true))
#expect(store.addChip(text: "c", label: "C", appendEnter: true))
// List.onMove 0 fromOffsets {0}, toOffset 3
store.moveChips(fromOffsets: IndexSet(integer: 0), toOffset: 3)
#expect(store.userChips.map(\.text) == ["b", "c", "a"])
}
@Test("损坏存档 → 空面板;异形数组 → 逐项过滤只留合法 chip存储边界不可信")
func corruptOrPartiallyInvalidArchiveIsFiltered() throws {
let suiteName = Self.makeSuiteName()
let defaults = try #require(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
// JSON []
defaults.set(Data("not json at all".utf8), forKey: QuickReplyStore.paletteDefaultsKey)
#expect(QuickReplyStore(defaults: defaults).userChips.isEmpty)
// []
defaults.set(Data(#"{"id":"user_a"}"#.utf8), forKey: QuickReplyStore.paletteDefaultsKey)
#expect(QuickReplyStore(defaults: defaults).userChips.isEmpty)
// web isValidChip filter
let mixed = #"""
[
{"id":"user_a","text":"yes","label":"yes","appendEnter":true},
{"id":42,"text":"x","label":"x","appendEnter":true},
"junk",
{"id":"user_b","text":"go","label":"Go"},
{"id":"user_c","text":"go","label":"Go","appendEnter":false}
]
"""#
defaults.set(Data(mixed.utf8), forKey: QuickReplyStore.paletteDefaultsKey)
#expect(QuickReplyStore(defaults: defaults).userChips == [
QuickReplyChip(id: "user_a", text: "yes", label: "yes", appendEnter: true),
QuickReplyChip(id: "user_c", text: "go", label: "Go", appendEnter: false),
])
}
// MARK: - waiting = gateread-only
@Test("无 gate → 隐藏gate 升起tool/plan 均可)→ 浮出gate 解除 → 隐藏")
func visibilityFollowsHeldGate() async throws {
let harness = try Harness()
await harness.openAndAdopt()
#expect(!harness.isBarVisible)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
await harness.waitBothProcessed(eventCount: 4)
#expect(harness.isBarVisible)
await harness.transport.emit(frame: ServerFrames.status(pending: false))
await harness.waitBothProcessed(eventCount: 5)
#expect(!harness.isBarVisible)
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "plan"))
await harness.waitBothProcessed(eventCount: 6)
#expect(harness.isBarVisible)
}
@Test("exitedread-only→ 即使 gate 仍持牌也隐藏")
func exitedTerminalHidesChipsEvenWithHeldGate() async throws {
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(frame: ServerFrames.status(pending: true, gate: "tool"))
await harness.waitBothProcessed(eventCount: 4)
#expect(harness.isBarVisible)
await harness.transport.emit(frame: ServerFrames.exit(code: 0))
await harness.waitBothProcessed(eventCount: 5)
#expect(harness.terminalViewModel.isReadOnly)
#expect(!harness.isBarVisible)
}
// MARK: - TerminalViewModel engine wire
@Test("chip 点击 → input 帧(文本+\\r经有序泵上线")
func chipTapSendsInputFrame() async throws {
let harness = try Harness()
await harness.openAndAdopt()
let yes = QuickReplyPalette.builtInChips[0]
QuickReplyBar.send(yes, through: harness.terminalViewModel)
await harness.terminalViewModel.waitUntilForwarded(sendCount: 1)
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.input(data: "yes\r")),
])
}
@Test("快速连点:两帧按点击顺序上线,绝不交错(复用 VM 发送泵)")
func rapidDoubleTapStaysOrdered() async throws {
let harness = try Harness()
await harness.openAndAdopt()
let yes = QuickReplyPalette.builtInChips[0]
let one = QuickReplyPalette.builtInChips[2]
QuickReplyBar.send(yes, through: harness.terminalViewModel)
QuickReplyBar.send(one, through: harness.terminalViewModel)
await harness.terminalViewModel.waitUntilForwarded(sendCount: 2)
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
MessageCodec.encode(.input(data: "yes\r")),
MessageCodec.encode(.input(data: "1\r")),
])
}
@Test("exited 后点击 → VM read-only 守卫丢弃wire 上无 input 帧")
func tapOnExitedTerminalIsDropped() async throws {
let harness = try Harness()
await harness.openAndAdopt()
await harness.transport.emit(frame: ServerFrames.exit(code: 0))
await harness.waitBothProcessed(eventCount: 4)
QuickReplyBar.send(QuickReplyPalette.builtInChips[0], through: harness.terminalViewModel)
#expect(harness.terminalViewModel.droppedReadOnlyInputCount == 1)
#expect(await harness.wireFrames() == [
MessageCodec.encode(.attach(sessionId: nil, cwd: nil)),
])
}
// MARK: -
@Test("面板/编辑器文案:中文具名常量在位")
func panelCopyConstants() {
#expect(QuickReplyPanel.Copy.title == "常用语")
#expect(QuickReplyPanel.Copy.addSection == "添加新常用语")
#expect(QuickReplyPanel.Copy.customSection == "自定义常用语")
#expect(QuickReplyPanel.Copy.textPlaceholder == "要发送的文本")
#expect(QuickReplyPanel.Copy.labelPlaceholder == "标签(可选,默认同文本)")
#expect(QuickReplyPanel.Copy.appendEnterToggle == "发送后自动回车")
#expect(QuickReplyPanel.Copy.addButton == "添加")
#expect(QuickReplyPanel.Copy.emptyState == "还没有自定义常用语")
#expect(QuickReplyBar.Copy.managePhrases == "管理常用语")
}
}

View File

@@ -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 }
)

View 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`
///
///
/// - VMunread //
/// - TerminalViewModeldelegate adopted id
/// - 线controller title hook VMsuspendresume 线
/// coordinator closeTerminal last-seen title
/// WS = closeopendeep-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: - TerminalViewModeldelegate + 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 接到 VMsuspend→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: hookid adopted id
#expect(forwarded.map(\.1) == ["gen0", "gen1"])
#expect(forwarded.allSatisfy { $0.0 == adopted })
controller.teardown()
}
@Test("coordinatorcloseTerminal 记 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 }
}
}

View 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
/// nilP1
///
/// - 2
/// spawn FIFOpermit 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 为 nilP1 前服务器)→ 渲染一次后命中缓存(文档化降级)")
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 FIFOpermit 按排队顺序转移")
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 不匹配 → placeholderrenderer 不调")
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 → placeholderrenderer 不调")
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("几何出服务器 resizeRange0 / 1001→ placeholderrenderer 不调", 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 → 逐出 BA/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*1024src/config.ts:46env
//
#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
}

View 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)`
/// reversenewest-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 = .loadingload 前不渲染内容)")
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 值 50public/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: - digestforSession
@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 timelineIconpublic/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 formatHHMM24h HH:mm
@Test("timeLabel24h 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)
}
}