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("推送失败")) } }