diff --git a/BuildNotifier/Models/WatchedProject.swift b/BuildNotifier/Models/WatchedProject.swift index 08aade7..5547fb5 100644 --- a/BuildNotifier/Models/WatchedProject.swift +++ b/BuildNotifier/Models/WatchedProject.swift @@ -94,8 +94,7 @@ struct UserPreferences: Codable { var failureSound: String var productionBranches: [String] - // Menu bar deploy indicator - var showDeployLoader: Bool + // Menu bar build/deploy spinner var deployLoaderStyle: MenuBarDeployStyle static let `default` = UserPreferences( @@ -119,7 +118,6 @@ struct UserPreferences: Codable { playFailureSound: true, failureSound: CelebrationSound.defaultFailure.rawValue, productionBranches: ["main", "master"], - showDeployLoader: true, deployLoaderStyle: .arc ) @@ -160,7 +158,6 @@ struct UserPreferences: Codable { if let v = partial["playFailureSound"] as? Bool { prefs.playFailureSound = v } if let v = partial["failureSound"] as? String { prefs.failureSound = v } if let v = partial["productionBranches"] as? [String] { prefs.productionBranches = v } - if let v = partial["showDeployLoader"] as? Bool { prefs.showDeployLoader = v } if let v = partial["deployLoaderStyle"] as? String, let style = MenuBarDeployStyle(rawValue: v) { prefs.deployLoaderStyle = style } if let vercelData = try? JSONSerialization.data(withJSONObject: partial["watchedVercelProjects"] ?? []), diff --git a/BuildNotifier/Services/MenuBarItem.swift b/BuildNotifier/Services/MenuBarItem.swift index 0e923b3..349a3fa 100644 --- a/BuildNotifier/Services/MenuBarItem.swift +++ b/BuildNotifier/Services/MenuBarItem.swift @@ -59,17 +59,16 @@ final class MenuBarItem: NSObject { private var reasserted = false private var panelTop: CGFloat = 0 - private let itemWidth: CGFloat = 24 private let gap: CGFloat = 6 private let screenInset: CGFloat = 8 init(appState: AppState) { self.appState = appState - item = NSStatusBar.system.statusItem(withLength: 24) + item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) panel = MenuPanel(content: MenuBarRoot(appState: appState).environment(metrics)) super.init() - item.length = itemWidth + item.length = NSStatusItem.variableLength item.behavior = [] item.autosaveName = "Item-0" item.isVisible = true @@ -90,8 +89,15 @@ final class MenuBarItem: NSObject { /// is one-shot, so it re-arms itself after each change. private func renderGlyph() { withObservationTracking { - item.button?.image = MenuBarGlyph.image(for: appState) - item.button?.setAccessibilityLabel(MenuBarGlyph.accessibilityLabel(for: appState)) + let counts = appState.menuBarCounts + let status = appState.menuBarStatus + let title = MenuBarGlyph.title(status: status, counts: counts, pendingApprovals: appState.pendingApprovals.count) ?? "" + item.button?.image = MenuBarGlyph.image(status: status, appState: appState) + item.button?.title = title + item.button?.imagePosition = title.isEmpty ? .imageOnly : .imageLeft + item.button?.setAccessibilityLabel( + MenuBarGlyph.accessibilityLabel(status: status, counts: counts, pendingApprovals: appState.pendingApprovals.count) + ) } onChange: { [weak self] in Task { @MainActor in self?.renderGlyph() } } diff --git a/BuildNotifier/State/AppState.swift b/BuildNotifier/State/AppState.swift index 2b31b4a..3fc2bde 100644 --- a/BuildNotifier/State/AppState.swift +++ b/BuildNotifier/State/AppState.swift @@ -10,6 +10,34 @@ enum AppScreen { case main } +/// Aggregate build/deployment tallies across all watched projects. +struct StatusCounts { + var running = 0 + var failing = 0 + var passing = 0 + + /// Bucket one build/deploy by its terminal-ish state. Failure/running/success + /// are mutually exclusive per unit, so at most one bucket increments. + mutating func tally(isFailure: Bool, isRunning: Bool, isSuccess: Bool) { + if isFailure { failing += 1 } + else if isRunning { running += 1 } + else if isSuccess { passing += 1 } + } + + /// Priority order for the menu bar: pending approval > running > failing > + /// passing > unknown. Running outranks failing so an active build shows the + /// animated "building" icon rather than being masked by a stale failure. + /// Callers pass the pending-approval count separately since it isn't part of + /// the build/deploy tally. + func overallStatus(pendingApprovals: Int) -> OverallStatus { + if pendingApprovals > 0 { return .pendingApproval } + if running > 0 { return .running } + if failing > 0 { return .failing } + if passing > 0 { return .passing } + return .unknown + } +} + @MainActor @Observable final class AppState { @@ -105,65 +133,76 @@ final class AppState { hasCircleCIToken || hasVercelToken } - var overallStatus: OverallStatus { - // Check for pending approvals first - if !pendingApprovals.isEmpty { - return .pendingApproval - } - - // Check builds - look at the LATEST build per branch per project - var hasRunning = false - var hasFailure = false - var hasSuccess = false - - // CircleCI builds + /// Single pass over watched builds/deployments producing the counts the menu + /// bar needs. Uses the latest build per branch per project (and the latest + /// deployment per project) as the counted unit — the same unit overallStatus + /// reasons about. A build's status is one value, so the buckets are mutually + /// exclusive per unit. + var statusCounts: StatusCounts { + var counts = StatusCounts() + let keys = keyBranches + let now = Date() + for (_, builds) in buildsByProject { + // Latest build per branch (build numbers are monotonic within a branch). var branchLatest: [String: Build] = [:] for build in builds { let branch = build.branch ?? "unknown" - if branchLatest[branch] == nil || build.buildNum > (branchLatest[branch]?.buildNum ?? 0) { + if branchLatest[branch].map({ build.buildNum > $0.buildNum }) ?? true { branchLatest[branch] = build } } - - for (_, build) in branchLatest { + + // Parse each branch's activity date once - it's reused by both the + // recency-ranked sort and the staleness check below, and parsing isn't + // free on the per-redraw hot path. + let dateByBranch = branchLatest.compactMapValues { $0.activityDate } + + // Visible set: the most-recently-active branches, same as the popover. + let visible = Set( + branchLatest.keys + .sorted { (dateByBranch[$0] ?? .distantPast) > (dateByBranch[$1] ?? .distantPast) } + .prefix(Self.maxBranchesPerProject) + ) + + for (branch, build) in branchLatest { + // A key branch (main/develop/production) always counts. Any other + // branch counts only while it's both visible and not stale, so + // abandoned feature branches stop pinning the badge to "failing". + if !keys.contains(branch) { + guard visible.contains(branch) else { continue } + if let date = dateByBranch[branch], + now.timeIntervalSince(date) > Self.statusRecencyWindow { continue } + } let status = build.buildStatus - if status.isRunning { hasRunning = true } - if status.isFailure { hasFailure = true } - if status.isSuccess { hasSuccess = true } + counts.tally(isFailure: status.isFailure, isRunning: status.isRunning, isSuccess: status.isSuccess) } } - - // Vercel deployments + for (_, deployments) in deploymentsByProject { - if let latest = deployments.first { - let status = latest.deploymentStatus - if status.isRunning { hasRunning = true } - if status.isFailure { hasFailure = true } - if status.isSuccess { hasSuccess = true } - } + guard let deploy = deployments.first else { continue } + // Production deploys always count; previews only while recent. + if !deploy.isProduction, + now.timeIntervalSince(deploy.createdDate) > Self.statusRecencyWindow { continue } + let status = deploy.deploymentStatus + counts.tally(isFailure: status.isFailure, isRunning: status.isRunning, isSuccess: status.isSuccess) } - - if hasFailure { return .failing } - if hasRunning { return .running } - if hasSuccess { return .passing } - return .unknown + + return counts } - var hasActiveBuildActivity: Bool { - for (_, builds) in buildsByProject { - if builds.contains(where: { $0.buildStatus.isRunning }) { - return true - } - } + /// Non-key branches older than this stop counting toward the menu bar status, + /// so an abandoned branch whose last build failed doesn't pin the badge. + private static let statusRecencyWindow: TimeInterval = 7 * 24 * 60 * 60 - for (_, deployments) in deploymentsByProject { - if deployments.contains(where: { $0.deploymentStatus.isRunning }) { - return true - } - } + /// Branches that always count toward the menu bar status, even when stale or + /// crowded out of the visible list - the ones you always want to know about. + private var keyBranches: Set { + Set(preferences.productionBranches + ["develop"]) + } - return false + var overallStatus: OverallStatus { + statusCounts.overallStatus(pendingApprovals: pendingApprovals.count) } /// A deploy to any tracked environment is in flight (its workflow is still @@ -179,28 +218,40 @@ final class AppState { /// reads it, which is the same path that swaps the idle/spinner/approval icon. var deploySpinnerPhase: Double = 0 private var deploySpinnerTimer: Timer? - private let deploySpinnerFPS: Double = 20 + private var deploySpinnerStart: TimeInterval = 0 + private let deploySpinnerFPS: Double = 60 private let deploySpinnerPeriod: Double = 0.9 - /// Whether the animated loader should be spinning: any build/deploy is active - /// and the user hasn't turned the loader off. - private var wantsSpinnerAnimation: Bool { - preferences.showDeployLoader && hasActiveBuildActivity - } + /// Menu-bar status snapshot, recomputed once per poll in `refreshDeploySpinner` + /// rather than by the label. `overallStatus`/`statusCounts` parse ISO8601 build + /// timestamps (~1.5ms per pass), so recomputing them on every 60fps redraw while + /// the spinner animates would burn ~9% of a core; the label reads these cached + /// values instead and the animation path does no parsing. + private(set) var menuBarCounts = StatusCounts() + private(set) var menuBarStatus: OverallStatus = .unknown - /// Start or stop the spinner timer to match `wantsSpinnerAnimation`. Called by - /// the poller after it refreshes build state, and when the preference changes. + /// Recompute the cached menu-bar status and start/stop the spinner timer to + /// match it. Called by each poller right after it updates build/deploy state. func refreshDeploySpinner() { - if wantsSpinnerAnimation { + menuBarCounts = statusCounts + menuBarStatus = menuBarCounts.overallStatus(pendingApprovals: pendingApprovals.count) + + if menuBarStatus == .running { guard deploySpinnerTimer == nil else { return } - let step = (1.0 / deploySpinnerFPS) / deploySpinnerPeriod + // Derive the phase from elapsed monotonic time rather than accumulating + // a fixed step per tick, so a late or dropped frame snaps to the right + // angle instead of slowing the spin - the rotation stays at a constant + // velocity and reads smoothly even if the timer jitters. + deploySpinnerStart = ProcessInfo.processInfo.systemUptime let timer = Timer(timeInterval: 1.0 / deploySpinnerFPS, repeats: true) { [weak self] _ in MainActor.assumeIsolated { guard let self else { return } - self.deploySpinnerPhase = (self.deploySpinnerPhase + step) + let elapsed = ProcessInfo.processInfo.systemUptime - self.deploySpinnerStart + self.deploySpinnerPhase = (elapsed / self.deploySpinnerPeriod) .truncatingRemainder(dividingBy: 1) } } + timer.tolerance = 0 RunLoop.main.add(timer, forMode: .common) deploySpinnerTimer = timer } else { @@ -454,7 +505,14 @@ final class AppState { // MARK: - Actions + private var hasInitialized = false + func initialize() async { + // Runs once at launch (from the app delegate) - guard against the popover's + // fallback `.task` also firing it if the window opens before launch finishes. + guard !hasInitialized else { return } + hasInitialized = true + workflowApprovalSupport.removeAll() armedAutoApprovals.removeAll() await NotificationManager.shared.requestAuthorization() @@ -751,9 +809,13 @@ final class AppState { preferences.watchedProjects = [] preferences.save() regroupCards() + // Recompute from the now-cleared data so the menu bar doesn't keep showing + // the pre-sign-out status (and stops the spinner timer). Polling has ended, + // so no future poll would otherwise refresh the cache. + refreshDeploySpinner() currentScreen = .onboarding } - + func changeToken() { // Preserve watchlist but clear token and go to onboarding stopPolling() @@ -769,6 +831,7 @@ final class AppState { armedAutoApprovals = [:] // Note: watchedProjects is preserved regroupCards() + refreshDeploySpinner() currentScreen = .onboarding } @@ -901,6 +964,9 @@ final class AppState { preferences.watchedVercelProjects = [] preferences.selectedVercelTeamId = nil preferences.save() + // CircleCI may still be connected; recompute from remaining data so the + // badge drops the disconnected Vercel deploys instead of showing them stale. + refreshDeploySpinner() } // MARK: - Private @@ -1023,10 +1089,21 @@ enum OverallStatus { var menuBarIcon: String { switch self { case .passing: return "checkmark.circle.fill" - case .failing: return "exclamationmark.circle.fill" - case .running: return "arrow.triangle.2.circlepath.circle.fill" + case .failing: return "xmark.circle.fill" + case .running: return "arrow.triangle.2.circlepath.circle.fill" // unused: the label draws an animated spinner case .pendingApproval: return "pause.circle.fill" case .unknown: return "circle.dashed" } } + + /// Hybrid menu-bar tint: color only for attention states, nil for calm + /// states so they render monochrome/template and adapt to the menu bar. + /// Colors match the glyphs used by build rows (RowStatus). + var menuBarTint: Color? { + switch self { + case .failing: return AppChrome.danger + case .pendingApproval: return AppChrome.warning + case .passing, .running, .unknown: return nil + } + } } diff --git a/BuildNotifier/Views/MenuBar/MenuBarGlyph.swift b/BuildNotifier/Views/MenuBar/MenuBarGlyph.swift index 228dee9..b2f19cb 100644 --- a/BuildNotifier/Views/MenuBar/MenuBarGlyph.swift +++ b/BuildNotifier/Views/MenuBar/MenuBarGlyph.swift @@ -7,27 +7,56 @@ import AppKit enum MenuBarGlyph { @MainActor static func image(for appState: AppState) -> NSImage { - if !appState.pendingApprovals.isEmpty { + image(status: appState.overallStatus, appState: appState) + } + + @MainActor + static func image(status: OverallStatus, appState: AppState) -> NSImage { + switch status { + case .running: + return deploying(style: appState.preferences.deployLoaderStyle, phase: appState.deploySpinnerPhase) + case .failing: + return coloredSymbol(status.menuBarIcon, color: .systemRed) + case .pendingApproval: return approval + case .passing: + return symbol(status.menuBarIcon) + case .unknown: + return symbol(status.menuBarIcon) } - guard appState.hasActiveBuildActivity else { - return symbol("circle.dashed") - } - guard appState.preferences.showDeployLoader else { - return symbol("arrow.triangle.2.circlepath") + } + + @MainActor + static func title(for appState: AppState) -> String? { + title(status: appState.overallStatus, counts: appState.statusCounts, pendingApprovals: appState.pendingApprovals.count) + } + + static func title(status: OverallStatus, counts: StatusCounts, pendingApprovals: Int) -> String? { + switch status { + case .failing: return "\(counts.failing) failing" + case .pendingApproval: return "\(pendingApprovals) waiting" + case .running: return "building" + case .passing, .unknown: return nil } - return deploying(style: appState.preferences.deployLoaderStyle, phase: appState.deploySpinnerPhase) } @MainActor static func accessibilityLabel(for appState: AppState) -> String { - if !appState.pendingApprovals.isEmpty { - return "Approval pending" - } - if appState.isDeploying { - return "Deploying" + accessibilityLabel( + status: appState.overallStatus, + counts: appState.statusCounts, + pendingApprovals: appState.pendingApprovals.count + ) + } + + static func accessibilityLabel(status: OverallStatus, counts: StatusCounts, pendingApprovals: Int) -> String { + switch status { + case .passing: return "All builds passing" + case .failing: return "\(counts.failing) failing" + case .running: return "Builds running" + case .pendingApproval: return "\(pendingApprovals) approval(s) waiting" + case .unknown: return "No build data" } - return appState.hasActiveBuildActivity ? "Builds running" : "Idle" } // MARK: - Frames @@ -52,6 +81,15 @@ enum MenuBarGlyph { return image } + private static func coloredSymbol(_ name: String, color: NSColor) -> NSImage { + let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .semibold) + .applying(NSImage.SymbolConfiguration(hierarchicalColor: color)) + guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)? + .withSymbolConfiguration(config) else { return NSImage() } + image.isTemplate = false + return image + } + /// One frame of the deploy spinner, rotated by `phase` (0...1). static func deploying(style: MenuBarDeployStyle, phase: Double) -> NSImage { let degrees = phase * 360 diff --git a/BuildNotifier/Views/Settings/SettingsView.swift b/BuildNotifier/Views/Settings/SettingsView.swift index f4402ab..924dc4c 100644 --- a/BuildNotifier/Views/Settings/SettingsView.swift +++ b/BuildNotifier/Views/Settings/SettingsView.swift @@ -207,23 +207,10 @@ struct SettingsView: View { SettingsSection( title: "Menu Bar", - subtitle: "The spinning icon shown while a branch is deploying.", + subtitle: "The animated icon shown while a build or deploy is in flight.", systemImage: "menubar.rectangle" ) { - SettingsToggleRow( - title: "Show deploy loader", - isOn: Binding( - get: { appState.preferences.showDeployLoader }, - set: { - appState.preferences.showDeployLoader = $0 - appState.savePreferences() - } - ) - ) - - Divider() - - SettingsPickerRow(title: "Loader style") { + SettingsPickerRow(title: "Spinner style") { HStack(alignment: .center, spacing: 10) { DeployLoaderPreview(style: appState.preferences.deployLoaderStyle) @@ -243,7 +230,6 @@ struct SettingsView: View { .frame(width: 150, alignment: .trailing) } } - .disabled(!appState.preferences.showDeployLoader) } } } diff --git a/Tests/BuildNotifierTests/StatusCountsTests.swift b/Tests/BuildNotifierTests/StatusCountsTests.swift new file mode 100644 index 0000000..ccda8d1 --- /dev/null +++ b/Tests/BuildNotifierTests/StatusCountsTests.swift @@ -0,0 +1,268 @@ +import XCTest +@testable import BuildNotifier + +@MainActor +final class StatusCountsTests: XCTestCase { + func testEmptyStateIsUnknown() { + let appState = makeAppState() + XCTAssertEqual(appState.statusCounts.failing, 0) + XCTAssertEqual(appState.statusCounts.running, 0) + XCTAssertEqual(appState.statusCounts.passing, 0) + XCTAssertEqual(appState.overallStatus, .unknown) + } + + func testCountsSumBranchesAndRunningWinsOverFailing() { + let appState = makeAppState() + appState.buildsByProject = [ + "delta-exchange/api-console": [ + makeBuild(buildNum: 10, branch: "main", status: "failed"), + makeBuild(buildNum: 11, branch: "feature", status: "running") + ] + ] + + XCTAssertEqual(appState.statusCounts.failing, 1) + XCTAssertEqual(appState.statusCounts.running, 1) + XCTAssertEqual(appState.overallStatus, .running, "an active build must outrank a failure") + } + + func testLatestBuildPerBranchWins() { + let appState = makeAppState() + appState.buildsByProject = [ + "delta-exchange/api-console": [ + makeBuild(buildNum: 20, branch: "main", status: "success"), + makeBuild(buildNum: 21, branch: "main", status: "failed") + ] + ] + + // Only the higher build number (21, failed) counts for the branch. + XCTAssertEqual(appState.statusCounts.failing, 1) + XCTAssertEqual(appState.statusCounts.passing, 0) + XCTAssertEqual(appState.overallStatus, .failing) + } + + func testAllPassingIsPassing() { + let appState = makeAppState() + appState.buildsByProject = [ + "delta-exchange/api-console": [ + makeBuild(buildNum: 30, branch: "main", status: "success"), + makeBuild(buildNum: 31, branch: "develop", status: "success") + ] + ] + + XCTAssertEqual(appState.statusCounts.passing, 2) + XCTAssertEqual(appState.overallStatus, .passing) + } + + func testRunningWithoutFailureIsRunning() { + let appState = makeAppState() + appState.buildsByProject = [ + "delta-exchange/api-console": [ + makeBuild(buildNum: 40, branch: "main", status: "success"), + makeBuild(buildNum: 41, branch: "feature", status: "running") + ] + ] + + XCTAssertEqual(appState.statusCounts.running, 1) + XCTAssertEqual(appState.statusCounts.passing, 1) + XCTAssertEqual(appState.overallStatus, .running) + } + + func testVercelDeploymentsAreCounted() { + let appState = makeAppState() + appState.deploymentsByProject = [ + "web-building": [makeDeployment(uid: "d1", state: "BUILDING")], + "web-ready": [makeDeployment(uid: "d2", state: "READY")], + "web-error": [makeDeployment(uid: "d3", state: "ERROR")] + ] + + XCTAssertEqual(appState.statusCounts.running, 1) + XCTAssertEqual(appState.statusCounts.passing, 1) + XCTAssertEqual(appState.statusCounts.failing, 1) + XCTAssertEqual(appState.overallStatus, .running, "a running deploy outranks a failing one") + } + + func testOnlyFirstDeploymentPerProjectCounts() { + let appState = makeAppState() + appState.deploymentsByProject = [ + "web": [ + makeDeployment(uid: "newest", state: "BUILDING"), + makeDeployment(uid: "older", state: "READY") + ] + ] + + // statusCounts tallies `deployments.first` per project, so only the + // leading BUILDING deployment counts - the trailing READY one is ignored. + XCTAssertEqual(appState.statusCounts.running, 1) + XCTAssertEqual(appState.statusCounts.passing, 0) + XCTAssertEqual(appState.overallStatus, .running) + } + + func testStaleFeatureFailureExcludedButKeyBranchAlwaysCounts() { + let appState = makeAppState() + appState.buildsByProject = [ + "delta-exchange/api-console": [ + // Abandoned feature branch that failed a month ago: must not count. + makeBuild(buildNum: 10, branch: "old-feature", status: "failed", startTime: stale), + // main failed just as long ago, but a key branch always counts. + makeBuild(buildNum: 11, branch: "main", status: "failed", startTime: stale) + ] + ] + + XCTAssertEqual(appState.statusCounts.failing, 1, "only the key branch's failure counts") + XCTAssertEqual(appState.overallStatus, .failing) + } + + func testRecentFeatureBranchCounts() { + let appState = makeAppState() + appState.buildsByProject = [ + "delta-exchange/api-console": [ + makeBuild(buildNum: 20, branch: "feature", status: "running", startTime: recent) + ] + ] + + XCTAssertEqual(appState.statusCounts.running, 1) + XCTAssertEqual(appState.overallStatus, .running) + } + + func testFailureBeyondVisibleBranchLimitIsExcluded() { + let appState = makeAppState() + // Six recent non-key branches; only the five most-recent are visible, so + // the sixth (oldest, failed) drops out of the count even though it's fresh. + var builds = (0..<5).map { i in + makeBuild(buildNum: 100 + i, branch: "feat-\(i)", status: "success", + startTime: iso(Date(timeIntervalSinceNow: -Double(i) * 60))) + } + builds.append( + makeBuild(buildNum: 200, branch: "feat-old", status: "failed", + startTime: iso(Date(timeIntervalSinceNow: -3600))) + ) + appState.buildsByProject = ["delta-exchange/api-console": builds] + + XCTAssertEqual(appState.statusCounts.passing, 5) + XCTAssertEqual(appState.statusCounts.failing, 0, "the crowded-out sixth branch must not count") + XCTAssertEqual(appState.overallStatus, .passing) + } + + func testStalePreviewDeployExcludedButProductionAlwaysCounts() { + let appState = makeAppState() + appState.deploymentsByProject = [ + "preview-stale": [makeDeployment(uid: "p1", state: "ERROR", target: "preview", createdAt: 1_600_000_000_000)], + "prod": [makeDeployment(uid: "p2", state: "ERROR", target: "production", createdAt: 1_600_000_000_000)] + ] + + XCTAssertEqual(appState.statusCounts.failing, 1, "only the production deploy's failure counts") + } + + func testPendingApprovalTakesPrecedenceOverFailing() { + let appState = makeAppState() + appState.buildsByProject = [ + "delta-exchange/api-console": [ + makeBuild(buildNum: 50, branch: "main", status: "failed") + ] + ] + appState.pendingApprovals = [makePendingApproval(workflowId: "wf-approval")] + + // The failing build is still counted... + XCTAssertEqual(appState.statusCounts.failing, 1) + // ...but a pending approval outranks every build/deploy state. + XCTAssertEqual(appState.overallStatus, .pendingApproval) + } + + func testRefreshResetsCachedStatusWhenDataIsCleared() { + let appState = makeAppState() + appState.buildsByProject = [ + "delta-exchange/api-console": [makeBuild(buildNum: 60, branch: "main", status: "failed")] + ] + appState.refreshDeploySpinner() + XCTAssertEqual(appState.menuBarStatus, .failing) + XCTAssertEqual(appState.menuBarCounts.failing, 1) + + // Clearing data and refreshing - as sign-out/disconnect does - must reset + // the cached snapshot rather than leave the menu bar frozen on old state. + appState.buildsByProject = [:] + appState.refreshDeploySpinner() + XCTAssertEqual(appState.menuBarStatus, .unknown) + XCTAssertEqual(appState.menuBarCounts.failing, 0) + } + + // MARK: - Fixtures + + private func iso(_ date: Date) -> String { ISO8601DateFormatter().string(from: date) } + /// A month ago - safely outside the 7-day recency window. + private var stale: String { iso(Date(timeIntervalSinceNow: -30 * 24 * 60 * 60)) } + /// An hour ago - safely inside the recency window. + private var recent: String { iso(Date(timeIntervalSinceNow: -60 * 60)) } + + private func makeAppState() -> AppState { + AppState(poller: BuildPoller(), vercelPoller: VercelPoller(), autoApprovalPoller: AutoApprovalPoller()) + } + + private func makeBuild(buildNum: Int, branch: String, status: String, startTime: String? = nil) -> Build { + Build( + vcsUrl: "https://github.com/delta-exchange/api-console", + buildUrl: nil, + buildNum: buildNum, + branch: branch, + vcsRevision: nil, + committerName: nil, + committerEmail: nil, + authorName: nil, + authorEmail: nil, + subject: nil, + body: nil, + why: nil, + queuedAt: nil, + startTime: startTime, + stopTime: nil, + buildTimeMillis: nil, + username: "delta-exchange", + reponame: "api-console", + lifecycle: nil, + outcome: status, + status: status, + retryOf: nil, + workflows: WorkflowInfo(jobName: nil, workflowId: "wf-\(buildNum)", workflowName: nil), + pullRequests: nil + ) + } + + private func makeDeployment( + uid: String, + state: String, + target: String = "production", + createdAt: Int = 1_700_000_000_000 + ) -> VercelDeployment { + VercelDeployment( + uid: uid, + name: "web", + url: "\(uid).vercel.app", + state: state, + readyState: state, + createdAt: createdAt, + buildingAt: nil, + ready: nil, + meta: nil, + creator: nil, + target: target + ) + } + + private func makePendingApproval(workflowId: String) -> PendingApproval { + let job = WorkflowJob( + id: "job-1", + name: "hold", + projectSlug: "delta-exchange/api-console", + status: "on_hold", + type: "approval", + approvedBy: nil, + startedAt: nil, + stoppedAt: nil, + jobNumber: nil + ) + return PendingApproval( + workflowId: workflowId, + job: job, + build: makeBuild(buildNum: 1, branch: "main", status: "running") + ) + } +}