App layer, four sequential slices (a shared .xcodeproj means adding files regenerates it, so these could not run in parallel): - token UX end to end: pairing prompts for a token when a host 401s, POST /auth validates it, and 204-without-Set-Cookie is correctly read as "this server has auth disabled" rather than "authenticated". A host paired before the token was turned on recovers by re-pairing in place. Remove-host now exists and finally gives PushRegistrar.handleHostRemoved a caller. - project git panel + worktree lifecycle (T-iOS-32) + claude --resume history — the parity gap with Android and the web front end. - terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a session switch between dictation and confirm cannot inject into the wrong session. - theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no longer hard-locks .preferredColorScheme(.dark). Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that collided with the upstream one, verified green from a fresh derivedDataPath. Includes the two HIGH fixes the security review found: - iOS resolved the WS token host-independently, so a token-gated host sitting next to an open one could never open a terminal and no on-screen remedy could fix it. Now one transport per host; cross-host leakage is structurally impossible since both read paths return only that host's own value. - Android reported the host's own git-credential 401 (git-ops.ts:108, "Push authentication required on the host.") as "your access token is wrong", because a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now ROUTE_DEFINED and keep the server's message. And the doc sync: README/ios README no longer claim the client is unmerged on feat/ios-client, the Clients section finally lists Android, and the plan checkboxes reflect what is actually built. iOS 534 app tests + 452 package tests; Android 687 tests.
225 lines
8.2 KiB
Swift
225 lines
8.2 KiB
Swift
import APIClient
|
||
import Foundation
|
||
import Testing
|
||
@testable import WebTerm
|
||
|
||
/// C2 · 纯呈现层(同步带 / 未推送边界 / 相对时间 / 写失败文案)。
|
||
///
|
||
/// 真源 `docs/plans/w6-project-git-panel.md` 与 web 参照实现
|
||
/// `public/projects.ts:615-745 makeSyncBand` —— 本套用例逐条钉住那份"只有一种
|
||
/// 状态可以显示为绿色"的规则(RED 清单 36–47,见 docs/plans/ios-completion.md §4)。
|
||
@Suite("GitPanelPresentation")
|
||
struct GitPanelPresentationTests {
|
||
/// 固定"现在":2026-07-30T12:00:00Z 的毫秒值,避免用例依赖真实时钟。
|
||
private static let nowMs: Double = 1_785_412_800_000
|
||
|
||
private static func minutesAgo(_ minutes: Double) -> Double {
|
||
nowMs - minutes * 60 * 1000
|
||
}
|
||
|
||
// MARK: - 同步带(RED 36–43)
|
||
|
||
@Test("非 git 目录(sync == nil)→ 无同步带")
|
||
func nonGitHasNoBand() {
|
||
#expect(GitSyncBand.make(sync: nil, dirtyCount: nil, nowMs: Self.nowMs) == nil)
|
||
}
|
||
|
||
@Test("↑0 ↓0 + 新鲜 fetch → 唯一允许的「已同步」绿色态")
|
||
func inSyncNeedsFreshFetch() throws {
|
||
let band = try #require(GitSyncBand.make(
|
||
sync: SyncState(
|
||
upstream: "origin/develop", ahead: 0, behind: 0,
|
||
lastFetchMs: Self.minutesAgo(5)
|
||
),
|
||
dirtyCount: 0, nowMs: Self.nowMs
|
||
))
|
||
|
||
#expect(band.isInSync)
|
||
#expect(band.isBehindVerified)
|
||
#expect(band.canFetch)
|
||
#expect(band.head == .tracking(
|
||
upstream: "origin/develop", ahead: 0, behind: 0
|
||
))
|
||
}
|
||
|
||
@Test("↑0 ↓0 但 fetch 已过期(> FETCH_STALE_MS)→ 不绿,↓ 未核实")
|
||
func staleFetchIsNeverGreen() throws {
|
||
let band = try #require(GitSyncBand.make(
|
||
sync: SyncState(
|
||
upstream: "origin/develop", ahead: 0, behind: 0,
|
||
lastFetchMs: Self.minutesAgo(61)
|
||
),
|
||
dirtyCount: 0, nowMs: Self.nowMs
|
||
))
|
||
|
||
#expect(!band.isInSync)
|
||
#expect(!band.isBehindVerified)
|
||
}
|
||
|
||
@Test("FETCH_STALE_MS 就是 1 小时(镜像 public/projects.ts:615)")
|
||
func staleThresholdMatchesWeb() {
|
||
#expect(GitSyncBand.fetchStaleMs == 60 * 60 * 1000)
|
||
}
|
||
|
||
@Test("从未 fetch(lastFetchMs == nil)→ 视为过期,不绿")
|
||
func neverFetchedIsStale() throws {
|
||
let band = try #require(GitSyncBand.make(
|
||
sync: SyncState(upstream: "origin/develop", ahead: 0, behind: 0, lastFetchMs: nil),
|
||
dirtyCount: 0, nowMs: Self.nowMs
|
||
))
|
||
|
||
#expect(!band.isInSync)
|
||
#expect(!band.isBehindVerified)
|
||
}
|
||
|
||
@Test("无上游 → 「无上游」,没有 ↑↓,绝不绿(最易犯的 bug)")
|
||
func noUpstreamIsNeverGreen() throws {
|
||
let band = try #require(GitSyncBand.make(
|
||
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: Self.nowMs),
|
||
dirtyCount: 0, nowMs: Self.nowMs
|
||
))
|
||
|
||
#expect(band.head == .noUpstream)
|
||
#expect(!band.isInSync)
|
||
#expect(band.canFetch)
|
||
}
|
||
|
||
@Test("分离 HEAD → 「分离 HEAD」,fetch 禁用,无 ↑↓")
|
||
func detachedDisablesFetch() throws {
|
||
let band = try #require(GitSyncBand.make(
|
||
sync: SyncState(upstream: nil, ahead: nil, behind: nil, lastFetchMs: nil, detached: true),
|
||
dirtyCount: nil, nowMs: Self.nowMs
|
||
))
|
||
|
||
#expect(band.head == .detached)
|
||
#expect(!band.canFetch)
|
||
#expect(!band.isInSync)
|
||
}
|
||
|
||
@Test("ahead 读不到(nil)→ 保留 nil(渲染 ↑ —),且不绿")
|
||
func unknownAheadIsNotZero() throws {
|
||
let band = try #require(GitSyncBand.make(
|
||
sync: SyncState(
|
||
upstream: "origin/develop", ahead: nil, behind: 0,
|
||
lastFetchMs: Self.minutesAgo(1)
|
||
),
|
||
dirtyCount: 0, nowMs: Self.nowMs
|
||
))
|
||
|
||
#expect(band.head == .tracking(upstream: "origin/develop", ahead: nil, behind: 0))
|
||
#expect(!band.isInSync)
|
||
}
|
||
|
||
@Test(
|
||
"dirtyCount:nil → 未检查(不是 clean)、0 → clean、3 → changed(3)",
|
||
arguments: [
|
||
(Int?.none, GitSyncBand.Dirty.unknown),
|
||
(Int?(0), GitSyncBand.Dirty.clean),
|
||
(Int?(3), GitSyncBand.Dirty.changed(3)),
|
||
] as [(Int?, GitSyncBand.Dirty)]
|
||
)
|
||
func dirtyTriState(dirtyCount: Int?, expected: GitSyncBand.Dirty) throws {
|
||
let band = try #require(GitSyncBand.make(
|
||
sync: SyncState(upstream: "origin/x", ahead: 0, behind: 0, lastFetchMs: Self.nowMs),
|
||
dirtyCount: dirtyCount, nowMs: Self.nowMs
|
||
))
|
||
|
||
#expect(band.dirty == expected)
|
||
}
|
||
|
||
// MARK: - 未推送边界(RED 44–46)
|
||
|
||
@Test("边界画在最后一个 unpushed 之后,且只画一次")
|
||
func boundaryAfterLastUnpushed() {
|
||
let commits = [
|
||
CommitLogEntry(hash: "aaa", at: 3, subject: "c", unpushed: true),
|
||
CommitLogEntry(hash: "bbb", at: 2, subject: "b", unpushed: true),
|
||
CommitLogEntry(hash: "ccc", at: 1, subject: "a", unpushed: false),
|
||
]
|
||
|
||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
|
||
}
|
||
|
||
@Test("unpushed 只在严格 true 时生效(nil ≠ false ≠ true)")
|
||
func nilUnpushedDrawsNoBoundary() {
|
||
let commits = [
|
||
CommitLogEntry(hash: "aaa", at: 2, subject: "c", unpushed: nil),
|
||
CommitLogEntry(hash: "bbb", at: 1, subject: "b", unpushed: false),
|
||
]
|
||
|
||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == nil)
|
||
}
|
||
|
||
@Test("无上游 → 完全不画边界(没有可比对的东西)")
|
||
func noUpstreamDrawsNoBoundary() {
|
||
let commits = [CommitLogEntry(hash: "aaa", at: 1, subject: "c", unpushed: true)]
|
||
|
||
#expect(GitLogBoundary.index(commits: commits, upstream: nil) == nil)
|
||
}
|
||
|
||
@Test("未推送提交排在已推送之下(老日期 merge)→ 边界仍在最后一个 unpushed 之后")
|
||
func interleavedUnpushedStillBounds() {
|
||
let commits = [
|
||
CommitLogEntry(hash: "aaa", at: 5, subject: "new pushed", unpushed: false),
|
||
CommitLogEntry(hash: "bbb", at: 4, subject: "merge of old branch", unpushed: true),
|
||
CommitLogEntry(hash: "ccc", at: 3, subject: "older pushed", unpushed: false),
|
||
]
|
||
|
||
#expect(GitLogBoundary.index(commits: commits, upstream: "origin/develop") == 1)
|
||
}
|
||
|
||
// MARK: - 相对时间
|
||
|
||
@Test(
|
||
"相对时间:秒/分/时/天各档非空且互不相同",
|
||
arguments: [0.5, 5, 90, 60 * 26] as [Double]
|
||
)
|
||
func relativeTimeBuckets(minutes: Double) {
|
||
let label = GitTimeFormat.relative(fromMs: Self.minutesAgo(minutes), nowMs: Self.nowMs)
|
||
|
||
#expect(!label.isEmpty)
|
||
}
|
||
|
||
@Test("未来时间戳(主机时钟漂移)→ 退化为「刚刚」,不出现负数")
|
||
func futureTimestampDegrades() {
|
||
let label = GitTimeFormat.relative(fromMs: Self.nowMs + 60_000, nowMs: Self.nowMs)
|
||
|
||
#expect(!label.contains("-"))
|
||
}
|
||
|
||
// MARK: - 写失败文案(RED 47)
|
||
|
||
@Test("服务器安全文案原样显示(绝不吞、绝不自造)")
|
||
func serverMessageIsSurfacedVerbatim() {
|
||
let text = GitWriteFeedback.message(
|
||
status: 409, serverMessage: "Nothing staged to commit.", fallback: "兜底"
|
||
)
|
||
|
||
#expect(text.contains("Nothing staged to commit."))
|
||
}
|
||
|
||
@Test("服务器没给 message → 兜底中文文案(非空)")
|
||
func missingServerMessageFallsBack() {
|
||
let text = GitWriteFeedback.message(status: 500, serverMessage: nil, fallback: "提交失败")
|
||
|
||
#expect(!text.isEmpty)
|
||
#expect(text.contains("提交失败"))
|
||
}
|
||
|
||
@Test("抛出的 APIClientError → 其自带话术(.unauthorized 引导补令牌)")
|
||
func thrownAPIErrorUsesItsOwnCopy() {
|
||
let text = GitWriteFeedback.message(for: APIClientError.unauthorized, fallback: "兜底")
|
||
|
||
#expect(text == APIClientError.unauthorized.message)
|
||
}
|
||
|
||
@Test("非 APIClientError(传输层)→ 兜底文案,不 crash")
|
||
func transportErrorFallsBack() {
|
||
let text = GitWriteFeedback.message(
|
||
for: URLError(.notConnectedToInternet), fallback: "推送失败"
|
||
)
|
||
|
||
#expect(text.contains("推送失败"))
|
||
}
|
||
}
|