diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index a9a896e..8471b87 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -12,12 +12,14 @@ struct CachedPayload { } struct PayloadCacheKey: Hashable { + let scope: MenubarScope let period: Period let provider: ProviderFilter let day: String? let days: Set - init(period: Period, provider: ProviderFilter, day: String? = nil, days: Set = []) { + init(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil, days: Set = []) { + self.scope = scope self.period = period self.provider = provider self.day = days.count <= 1 ? (day ?? days.first) : nil @@ -37,7 +39,13 @@ struct PayloadCacheKey: Hashable { final class AppStore { var selectedProvider: ProviderFilter = .all var selectedPeriod: Period = .today + var selectedScope: MenubarScope = MenubarScope.savedMenubarScope() var selectedDays: Set = [] + var activeScope: MenubarScope { effectiveSelectedScope } + + private var effectiveSelectedScope: MenubarScope { + selectedDays.count > 1 ? .local : selectedScope + } var selectedDay: String? { guard selectedDays.count == 1 else { return nil } @@ -46,6 +54,9 @@ final class AppStore { private(set) var menubarPeriod: Period = Period.savedMenubarPeriod() { didSet { menubarPeriod.persistAsMenubarDefault() } } + private(set) var menubarScope: MenubarScope = MenubarScope.savedMenubarScope() { + didSet { menubarScope.persistAsMenubarDefault() } + } var selectedInsight: InsightMode = .trend var accentPreset: AccentPreset = ThemeState.shared.preset { didSet { ThemeState.shared.preset = accentPreset } @@ -88,6 +99,10 @@ final class AppStore { return total >= activeDailyBudget } + var shouldShowDailyBudgetWarning: Bool { + isOverDailyBudget && activeScope == .local + } + /// The active daily-budget threshold formatted for display (tokens, or USD). /// The cost budget is defined in USD (matching the "$" presets and field), so /// it is not run through the display-currency conversion here. @@ -97,7 +112,10 @@ final class AppStore { var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } } var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 } - var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) } + var hasAttemptedCurrentKeyLoad: Bool { + attemptedKeys.contains(currentKey) || + (effectiveSelectedScope == .combined && attemptedKeys.contains(localCurrentKey)) + } var lastError: String? { lastErrorByKey[currentKey] } private var loadingCountsByKey: [PayloadCacheKey: Int] = [:] private var loadingStartedAtByKey: [PayloadCacheKey: Date] = [:] @@ -122,6 +140,9 @@ final class AppStore { private var cacheDate: String = "" private var switchTask: Task? private var payloadRefreshGeneration: UInt64 = 0 +#if DEBUG + private var refreshSuppressedForTesting = false +#endif /// Tracks the last successful fetch timestamp per key for stuck-loading /// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt` /// is authoritative there. This map persists across cache wipes (day @@ -146,19 +167,45 @@ final class AppStore { } private var todayAllKey: PayloadCacheKey { - PayloadCacheKey(period: .today, provider: .all, day: nil) + PayloadCacheKey(scope: .local, period: .today, provider: .all, day: nil) } private var menubarStatusKey: PayloadCacheKey { - PayloadCacheKey(period: menubarPeriod, provider: .all, day: nil) + PayloadCacheKey(scope: .local, period: menubarPeriod, provider: .all, day: nil) } private var currentKey: PayloadCacheKey { - PayloadCacheKey(period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays) + PayloadCacheKey(scope: effectiveSelectedScope, period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays) + } + + private var localCurrentKey: PayloadCacheKey { + PayloadCacheKey(scope: .local, period: selectedPeriod, provider: selectedProvider, day: selectedDay, days: selectedDays) + } + + private var periodAllKey: PayloadCacheKey { + PayloadCacheKey(scope: .local, period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays) } var payload: MenubarPayload { - cache[currentKey]?.payload ?? .empty + if effectiveSelectedScope == .combined { + let combinedPayload = cache[currentKey]?.payload + if let localPayload = cache[localCurrentKey]?.payload { + if let combined = combinedPayload?.combined { + return MenubarPayload( + generated: combinedPayload?.generated ?? localPayload.generated, + current: localPayload.current, + optimize: localPayload.optimize, + history: localPayload.history, + combined: combined + ) + } + return localPayload + } + if let combinedPayload { + return combinedPayload + } + } + return cache[currentKey]?.payload ?? .empty } /// Today (across all providers) backs day-specific views in the popover. @@ -187,7 +234,7 @@ final class AppStore { /// All-provider payload for the selected period. Used by the tab strip to show /// per-provider costs that match the active period, not just today. var periodAllPayload: MenubarPayload? { - cache[PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays)]?.payload + cache[periodAllKey]?.payload } var isDayMode: Bool { @@ -206,7 +253,7 @@ final class AppStore { } var hasCachedData: Bool { - cache[currentKey] != nil + cache[currentKey] != nil || (effectiveSelectedScope == .combined && cache[localCurrentKey] != nil) } var hasStaleLoading: Bool { @@ -221,7 +268,7 @@ final class AppStore { } var hasMissingInteractivePayloadWithoutAttempt: Bool { - cache[currentKey] == nil && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad + !hasCachedData && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad } var shouldResetInteractiveRefreshPipeline: Bool { @@ -231,8 +278,9 @@ final class AppStore { var staleInteractivePayloadAgeSeconds: Int? { let keys = Set([ currentKey, + localCurrentKey, todayAllKey, - PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays), + periodAllKey, ]) let staleAges = keys.compactMap { key -> TimeInterval? in guard let cached = cache[key] else { return nil } @@ -243,11 +291,11 @@ final class AppStore { } var needsInteractivePayloadRefresh: Bool { - let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all, day: selectedDay, days: selectedDays) - return cache[currentKey]?.isFresh != true || - cache[todayAllKey]?.isFresh != true || - cache[periodAllKey]?.isFresh != true || - hasStaleLoading + var requiredKeys: Set = [currentKey, todayAllKey, periodAllKey] + if effectiveSelectedScope == .combined { + requiredKeys.insert(localCurrentKey) + } + return requiredKeys.contains { cache[$0]?.isFresh != true } || hasStaleLoading } /// True if any cached payload reports at least one provider. Used to keep the @@ -259,16 +307,47 @@ final class AppStore { } #if DEBUG - func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, day: String? = nil, fetchedAt: Date) { - cache[PayloadCacheKey(period: period, provider: provider, day: day)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) + func setCachedPayloadForTesting(_ payload: MenubarPayload, + scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = [], + fetchedAt: Date) { + cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)] = CachedPayload(payload: payload, fetchedAt: fetchedAt) } - func seedInFlightForTesting(period: Period, provider: ProviderFilter, day: String? = nil, insertedAt: Date) { - inFlightKeys[PayloadCacheKey(period: period, provider: provider, day: day)] = insertedAt + func cachedPayloadForTesting(scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = []) -> MenubarPayload? { + cache[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)]?.payload } - func isInFlightForTesting(period: Period, provider: ProviderFilter, day: String? = nil) -> Bool { - inFlightKeys[PayloadCacheKey(period: period, provider: provider, day: day)] != nil + func setLastErrorForTesting(_ error: String, + scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + days: Set = []) { + lastErrorByKey[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days)] = error + } + + func seedInFlightForTesting(scope: MenubarScope = .local, + period: Period, + provider: ProviderFilter, + day: String? = nil, + insertedAt: Date) { + inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] = insertedAt + } + + func isInFlightForTesting(scope: MenubarScope = .local, period: Period, provider: ProviderFilter, day: String? = nil) -> Bool { + inFlightKeys[PayloadCacheKey(scope: scope, period: period, provider: provider, day: day)] != nil + } + + func suppressRefreshesForTesting() { + refreshSuppressedForTesting = true } #endif @@ -325,6 +404,23 @@ final class AppStore { } } + func setMenubarScope(_ scope: MenubarScope) { + let shouldResetProvider = scope == .combined && selectedProvider != .all + guard menubarScope != scope || selectedScope != scope || shouldResetProvider else { return } + menubarScope = scope + selectedScope = scope + if shouldResetProvider { + selectedProvider = .all + } +#if DEBUG + if refreshSuppressedForTesting { return } +#endif + Task { [weak self] in + guard let self else { return } + await self.refreshSelectionQuietly(scope: self.effectiveSelectedScope, force: true) + } + } + /// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only /// runs the CLI for the final selection. Fetches provider-specific and all-provider data /// in parallel so the tab strip costs stay in sync with the hero. @@ -333,21 +429,46 @@ final class AppStore { startInteractiveSelectionRefresh() } + func switchTo(scope: MenubarScope) { + let shouldResetProvider = scope == .combined && selectedProvider != .all + guard selectedScope != scope || shouldResetProvider else { return } + selectedScope = scope + if shouldResetProvider { + selectedProvider = .all + } + startInteractiveSelectionRefresh() + } + private func startInteractiveSelectionRefresh() { switchTask?.cancel() resetLoadingState() +#if DEBUG + if refreshSuppressedForTesting { return } +#endif let period = selectedPeriod let provider = selectedProvider + let scope = effectiveSelectedScope let day = selectedDay let days = selectedDays - let key = PayloadCacheKey(period: period, provider: provider, day: day, days: days) + let key = PayloadCacheKey(scope: scope, period: period, provider: provider, day: day, days: days) + let localKey = PayloadCacheKey(scope: .local, period: period, provider: provider, day: day, days: days) + let allKey = PayloadCacheKey(scope: .local, period: period, provider: .all, day: day, days: days) lastErrorByKey[key] = nil switchTask = Task { - if provider == .all { + if scope == .combined { + async let local: Void = refresh(key: localKey, includeOptimize: false, force: false, showLoading: false) + async let combined: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true) + if provider == .all { + _ = await (local, combined) + } else { + async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false) + _ = await (local, combined, all) + } + } else if provider == .all { await refresh(key: key, includeOptimize: false, force: true, showLoading: true) } else { async let main: Void = refresh(key: key, includeOptimize: false, force: true, showLoading: true) - async let all: Void = refreshQuietly(period: period, day: day) + async let all: Void = refreshQuietly(key: allKey, includeOptimize: false, force: false) _ = await (main, all) } } @@ -450,7 +571,7 @@ final class AppStore { func recoverFromStuckLoading() async { guard prepareStuckLoadingRecovery() else { return } - await refresh(key: currentKey, includeOptimize: false, force: true, showLoading: true) + await refresh(includeOptimize: false, force: true, showLoading: true) } /// Decides whether stuck-loading recovery should kick off a fresh fetch for @@ -476,7 +597,30 @@ final class AppStore { } func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { - await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading) + if effectiveSelectedScope == .combined { + async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: includeOptimize, force: force) + async let combined: Void = refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading) + _ = await (local, combined) + } else { + await refresh(key: currentKey, includeOptimize: includeOptimize, force: force, showLoading: showLoading) + } + } + + private func refreshSelectionQuietly(scope: MenubarScope, force: Bool = false) async { + let scopedKey = PayloadCacheKey( + scope: scope, + period: selectedPeriod, + provider: selectedProvider, + day: selectedDay, + days: selectedDays + ) + if scope == .combined { + async let local: Void = refreshQuietly(key: localCurrentKey, includeOptimize: false, force: false) + async let combined: Void = refreshQuietly(key: scopedKey, includeOptimize: false, force: force) + _ = await (local, combined) + } else { + await refreshQuietly(key: scopedKey, includeOptimize: false, force: force) + } } private func refresh(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async { @@ -515,7 +659,7 @@ final class AppStore { } } do { - let fresh = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: includeOptimize) + let fresh = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: includeOptimize, scope: key.scope) if generationAtStart != payloadRefreshGeneration { NSLog("CodeBurn: dropping fetch result for \(key.label)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch") return @@ -545,7 +689,7 @@ final class AppStore { NSLog("CodeBurn: fetch failed for \(key.label)/\(key.provider.rawValue): \(error)") if includeOptimize, cache[key] == nil { do { - let fallback = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: false) + let fallback = try await DataClient.fetch(period: key.period, day: key.day, days: key.days, provider: key.provider, includeOptimize: false, scope: key.scope) guard !Task.isCancelled else { return } if generationAtStart != payloadRefreshGeneration { return } if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() { @@ -564,9 +708,9 @@ final class AppStore { lastErrorByKey[key] = String(describing: error) } - let allKey = PayloadCacheKey(period: key.period, provider: .all, day: key.day) + let allKey = PayloadCacheKey(scope: .local, period: key.period, provider: .all, day: key.day, days: key.days) if key != allKey, cache[allKey]?.isFresh != true { - await refreshQuietly(period: key.period, day: key.day) + await refreshQuietly(key: allKey, includeOptimize: false, force: false) } } @@ -574,22 +718,32 @@ final class AppStore { /// Does not toggle isLoading, so the popover's loading overlay is unaffected. /// Always uses the .all provider since the menubar badge shows total spend. func refreshQuietly(period: Period, day: String? = nil, force: Bool = false) async { + await refreshQuietly(key: PayloadCacheKey(scope: .local, period: period, provider: .all, day: day), includeOptimize: false, force: force) + } + + private func refreshQuietly(key: PayloadCacheKey, includeOptimize: Bool, force: Bool = false) async { invalidateStaleDayCache() - let key = PayloadCacheKey(period: period, provider: .all, day: day) if !force, cache[key]?.isFresh == true { return } if inFlightKeys[key] != nil { return } inFlightKeys[key] = Date() attemptedKeys.insert(key) let cacheDateAtStart = cacheDate let generationAtStart = payloadRefreshGeneration - if day == nil && period == .today, let age = todayPayloadAgeSeconds, age > 120 { + if key.day == nil && key.period == .today, let age = todayPayloadAgeSeconds, age > 120 { NSLog("CodeBurn: refreshing stale today status payload after %ds", age) } defer { inFlightKeys[key] = nil } do { - let fresh = try await DataClient.fetch(period: period, day: day, provider: .all, includeOptimize: false) + let fresh = try await DataClient.fetch( + period: key.period, + day: key.day, + days: key.days, + provider: key.provider, + includeOptimize: includeOptimize, + scope: key.scope + ) if generationAtStart != payloadRefreshGeneration { NSLog("CodeBurn: dropping quiet fetch result for \(key.label) — refresh pipeline reset mid-fetch") return @@ -605,6 +759,9 @@ final class AppStore { lastErrorByKey[key] = nil } catch { NSLog("CodeBurn: quiet refresh failed for \(key.label): \(error)") + if key.scope == .combined { + lastErrorByKey[key] = String(describing: error) + } } } diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift index 7eabd4c..854fb2e 100644 --- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift +++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift @@ -40,25 +40,20 @@ struct CLIDecodeFailure: Error, CustomStringConvertible { /// Runs the CLI via argv (no shell interpretation). See `CodeburnCLI` for why we never route /// commands through `/bin/zsh -c` anymore. struct DataClient { - static func fetch(period: Period, day: String? = nil, days: Set = [], provider: ProviderFilter, includeOptimize: Bool) async throws -> MenubarPayload { - var subcommand = [ - "status", - "--format", "menubar-json", - "--provider", provider.cliArg, - ] - if days.count > 1 { - subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")]) - } else if let day { - subcommand.append(contentsOf: ["--day", day]) - } else if let d = days.first { - subcommand.append(contentsOf: ["--day", d]) - } else { - subcommand.append(contentsOf: ["--period", period.cliArg]) - } - if !includeOptimize { - subcommand.append("--no-optimize") - } - + static func fetch(period: Period, + day: String? = nil, + days: Set = [], + provider: ProviderFilter, + includeOptimize: Bool, + scope: MenubarScope = .local) async throws -> MenubarPayload { + let subcommand = statusSubcommand( + period: period, + day: day, + days: days, + provider: provider, + includeOptimize: includeOptimize, + scope: scope + ) let result = try await runCLI(subcommand: subcommand) guard result.exitCode == 0 else { throw DataClientError.nonZeroExit(code: result.exitCode, stderr: result.stderr) @@ -76,6 +71,37 @@ struct DataClient { } } + static func statusSubcommand(period: Period, + day: String? = nil, + days: Set = [], + provider: ProviderFilter, + includeOptimize: Bool, + scope: MenubarScope = .local) -> [String] { + let effectiveScope: MenubarScope = days.count > 1 ? .local : scope + let effectiveProvider: ProviderFilter = effectiveScope == .combined ? .all : provider + var subcommand = [ + "status", + "--format", "menubar-json", + "--provider", effectiveProvider.cliArg, + ] + if effectiveScope == .combined { + subcommand.append(contentsOf: ["--scope", effectiveScope.cliArg]) + } + if days.count > 1 { + subcommand.append(contentsOf: ["--days", days.sorted().joined(separator: ",")]) + } else if let day { + subcommand.append(contentsOf: ["--day", day]) + } else if let d = days.first { + subcommand.append(contentsOf: ["--day", d]) + } else { + subcommand.append(contentsOf: ["--period", period.cliArg]) + } + if !includeOptimize { + subcommand.append("--no-optimize") + } + return subcommand + } + struct ProcessResult { let stdout: Data let stderr: String diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift index 50507c4..998412b 100644 --- a/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift +++ b/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift @@ -7,6 +7,40 @@ struct MenubarPayload: Codable, Sendable { let current: CurrentBlock let optimize: OptimizeBlock let history: HistoryBlock + let combined: CombinedUsage? +} + +struct CombinedUsage: Codable, Sendable { + let perDevice: [CombinedDeviceUsage] + let combined: CombinedUsageTotals +} + +struct CombinedDeviceUsage: Codable, Sendable { + let id: String + let name: String + let local: Bool + let error: String? + let cost: Double + let calls: Int + let sessions: Int + let inputTokens: Int + let outputTokens: Int + let cacheCreateTokens: Int + let cacheReadTokens: Int + let totalTokens: Int +} + +struct CombinedUsageTotals: Codable, Sendable { + let cost: Double + let calls: Int + let sessions: Int + let inputTokens: Int + let outputTokens: Int + let cacheCreateTokens: Int + let cacheReadTokens: Int + let totalTokens: Int + let deviceCount: Int + let reachableCount: Int } struct HistoryBlock: Codable, Sendable { @@ -387,6 +421,7 @@ extension MenubarPayload { mcpServers: [] ), optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), - history: HistoryBlock(daily: []) + history: HistoryBlock(daily: []), + combined: nil ) } diff --git a/mac/Sources/CodeBurnMenubar/MenubarScope.swift b/mac/Sources/CodeBurnMenubar/MenubarScope.swift new file mode 100644 index 0000000..0ffe6bb --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/MenubarScope.swift @@ -0,0 +1,39 @@ +import Foundation + +private let menubarScopeDefaultsKey = "CodeBurnMenubarScope" + +enum MenubarScope: String, CaseIterable, Identifiable, Sendable { + case local = "Local" + case combined = "Combined" + + var id: String { rawValue } + + var cliArg: String { + switch self { + case .local: "local" + case .combined: "combined" + } + } + + var menubarDefaultsValue: String { + switch self { + case .local: "local" + case .combined: "combined" + } + } + + init(menubarDefaultsValue: String?) { + switch menubarDefaultsValue { + case "combined": self = .combined + default: self = .local + } + } + + static func savedMenubarScope(defaults: UserDefaults = .standard) -> MenubarScope { + MenubarScope(menubarDefaultsValue: defaults.string(forKey: menubarScopeDefaultsKey)) + } + + func persistAsMenubarDefault(defaults: UserDefaults = .standard) { + defaults.set(menubarDefaultsValue, forKey: menubarScopeDefaultsKey) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift index de4f24a..e05adfe 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeroSection.swift @@ -27,7 +27,7 @@ struct HeroSection: View { HStack(spacing: 2) { Image(systemName: "arrow.up") .font(.system(size: 9, weight: .semibold)) - Text(formatTokens(Double(store.payload.current.outputTokens))) + Text(formatTokens(Double(totals.outputTokens))) } .font(.system(size: 11)) .monospacedDigit() @@ -35,17 +35,17 @@ struct HeroSection: View { HStack(spacing: 2) { Image(systemName: "arrow.down") .font(.system(size: 9, weight: .semibold)) - Text(formatTokens(Double(store.payload.current.inputTokens))) + Text(formatTokens(Double(totals.inputTokens))) } .font(.system(size: 10.5)) .monospacedDigit() .foregroundStyle(.tertiary) } else { - Text("\(store.payload.current.calls.asThousandsSeparated()) calls") + Text("\(totals.calls.asThousandsSeparated()) calls") .font(.system(size: 11)) .monospacedDigit() .foregroundStyle(.secondary) - Text("\(store.payload.current.sessions) sessions") + Text("\(totals.sessions) sessions") .font(.system(size: 10.5)) .monospacedDigit() .foregroundStyle(.tertiary) @@ -55,7 +55,7 @@ struct HeroSection: View { if !store.isDayMode, store.selectedPeriod == .today, - store.isOverDailyBudget { + store.shouldShowDailyBudgetWarning { HStack(spacing: 4) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 10)) @@ -66,6 +66,18 @@ struct HeroSection: View { .padding(.top, 2) } + if let usage = combinedUsage { + CombinedDeviceBreakdown(usage: usage, formatTokens: formatTokens) + } else if store.activeScope == .combined, store.lastError != nil { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 10)) + Text("Combined unavailable · showing local") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.secondary) + } + if let savingsCaption { HStack(spacing: 4) { Image(systemName: "leaf.fill") @@ -83,13 +95,22 @@ struct HeroSection: View { private var heroText: String { if store.displayMetric == .tokens || store.displayMetric == .totalTokens { - let total = Double(store.payload.current.inputTokens + store.payload.current.outputTokens) + let total = Double(totals.totalTokens) if total >= 1_000_000_000 { return String(format: "%.2fB tok", total / 1_000_000_000) } if total >= 1_000_000 { return String(format: "%.1fM tok", total / 1_000_000) } if total >= 1_000 { return String(format: "%.0fK tok", total / 1_000) } return String(format: "%.0f tok", total) } - return store.payload.current.cost.asCurrency() + return totals.cost.asCurrency() + } + + private var combinedUsage: CombinedUsage? { + guard store.activeScope == .combined else { return nil } + return store.payload.combined + } + + private var totals: HeroTotals { + HeroTotals(payload: store.payload, activeScope: store.activeScope) } private func formatTokens(_ n: Double) -> String { @@ -101,6 +122,9 @@ struct HeroSection: View { private var caption: String { let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label + if combinedUsage != nil { + return "Combined · \(label)" + } if !store.isDayMode && store.selectedPeriod == .today { return "\(label) · \(todayDate)" } @@ -113,6 +137,7 @@ struct HeroSection: View { /// (above) and hypothetical avoided spend (below) never get summed /// into a misleading "real cost" by the reader. private var savingsCaption: String? { + guard combinedUsage == nil else { return nil } let savings = store.payload.current.localModelSavings.totalUSD guard savings > 0 else { return nil } return "Saved \(savings.asCurrency()) with local models" @@ -124,3 +149,86 @@ struct HeroSection: View { return formatter.string(from: Date()) } } + +struct HeroTotals: Equatable { + let cost: Double + let calls: Int + let sessions: Int + let inputTokens: Int + let outputTokens: Int + let totalTokens: Int + + init(cost: Double, calls: Int, sessions: Int, inputTokens: Int, outputTokens: Int, totalTokens: Int) { + self.cost = cost + self.calls = calls + self.sessions = sessions + self.inputTokens = inputTokens + self.outputTokens = outputTokens + self.totalTokens = totalTokens + } + + init(payload: MenubarPayload, activeScope: MenubarScope) { + if activeScope == .combined, let combined = payload.combined?.combined { + self.init( + cost: combined.cost, + calls: combined.calls, + sessions: combined.sessions, + inputTokens: combined.inputTokens, + outputTokens: combined.outputTokens, + totalTokens: combined.inputTokens + combined.outputTokens + ) + return + } + + let current = payload.current + self.init( + cost: current.cost, + calls: current.calls, + sessions: current.sessions, + inputTokens: current.inputTokens, + outputTokens: current.outputTokens, + totalTokens: current.inputTokens + current.outputTokens + ) + } +} + +private struct CombinedDeviceBreakdown: View { + let usage: CombinedUsage + let formatTokens: (Double) -> String + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 4) { + Image(systemName: "desktopcomputer") + .font(.system(size: 10)) + Text("\(usage.combined.reachableCount) of \(usage.combined.deviceCount) devices") + .font(.system(size: 11, weight: .medium)) + } + .foregroundStyle(.secondary) + + VStack(spacing: 3) { + ForEach(usage.perDevice, id: \.id) { device in + HStack(spacing: 6) { + Image(systemName: device.error == nil ? "circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: device.error == nil ? 5 : 9, weight: .semibold)) + .foregroundStyle(device.error == nil ? Color.secondary.opacity(0.75) : Theme.semanticWarning) + .frame(width: 10) + Text(device.local ? "\(device.name) · local" : device.name) + .font(.system(size: 10.5, weight: .medium)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 6) + Text(device.error == nil ? device.cost.asCurrency() : "Unavailable") + .font(.system(size: 10.5)) + .monospacedDigit() + .foregroundStyle(.secondary) + Text(formatTokens(Double(device.totalTokens))) + .font(.system(size: 10)) + .monospacedDigit() + .foregroundStyle(.tertiary) + } + } + } + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index 389f909..4e00381 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -22,6 +22,7 @@ struct MenuBarContent: View { HeroSection() Divider().opacity(0.5) PeriodSegmentedControl() + ScopeSegmentedControl() Divider().opacity(0.5) if isFilteredEmpty { EmptyProviderState(provider: store.selectedProvider, periodLabel: store.selectionLabel) @@ -120,6 +121,41 @@ struct MenuBarContent: View { } +private struct ScopeSegmentedControl: View { + @Environment(AppStore.self) private var store + + var body: some View { + HStack(spacing: 1) { + ForEach(MenubarScope.allCases) { scope in + let isActive = store.activeScope == scope + Button { + store.switchTo(scope: scope) + } label: { + Text(scope.rawValue) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(isActive ? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary)) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(isActive ? Color(NSColor.windowBackgroundColor).opacity(0.85) : .clear) + .shadow(color: .black.opacity(isActive ? 0.06 : 0), radius: 1, y: 0.5) + ) + } + } + .padding(2) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color.secondary.opacity(0.08)) + ) + .padding(.horizontal, 12) + .padding(.bottom, 10) + } +} + private struct EmptyProviderState: View { let provider: ProviderFilter let periodLabel: String diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index b613510..3a62587 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -103,6 +103,15 @@ private struct GeneralSettingsTab: View { } } .pickerStyle(.menu) + Picker("Scope", selection: Binding( + get: { store.menubarScope }, + set: { store.setMenubarScope($0) } + )) { + ForEach(MenubarScope.allCases) { scope in + Text(scope.rawValue).tag(scope) + } + } + .pickerStyle(.menu) Picker("Accent", selection: Binding( get: { store.accentPreset }, set: { store.accentPreset = $0 } diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift index 1314fbd..02aae47 100644 --- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift @@ -2,7 +2,40 @@ import Foundation import Testing @testable import CodeBurnMenubar -private func menubarPayload(cost: Double) -> MenubarPayload { +private func combinedUsage(cost: Double = 12.5) -> CombinedUsage { + CombinedUsage( + perDevice: [ + CombinedDeviceUsage( + id: "local", + name: "MacBook", + local: true, + error: nil, + cost: cost, + calls: 3, + sessions: 2, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180 + ) + ], + combined: CombinedUsageTotals( + cost: cost, + calls: 3, + sessions: 2, + inputTokens: 100, + outputTokens: 50, + cacheCreateTokens: 10, + cacheReadTokens: 20, + totalTokens: 180, + deviceCount: 1, + reachableCount: 1 + ) + ) +} + +private func menubarPayload(cost: Double, combined: CombinedUsage? = nil) -> MenubarPayload { MenubarPayload( generated: "test", current: CurrentBlock( @@ -30,7 +63,8 @@ private func menubarPayload(cost: Double) -> MenubarPayload { mcpServers: [] ), optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), - history: HistoryBlock(daily: []) + history: HistoryBlock(daily: []), + combined: combined ) } @@ -74,6 +108,140 @@ struct AppStoreRefreshRecoveryTests { #expect(!store.shouldResetInteractiveRefreshPipeline) } + @Test("payload cache partitions local and combined scope") + func payloadCachePartitionsByScope() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 10), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + store.setCachedPayloadForTesting( + menubarPayload(cost: 99, combined: combinedUsage(cost: 42)), + scope: .combined, + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10) + #expect(store.cachedPayloadForTesting(scope: .combined, period: .today, provider: .all)?.current.cost == 99) + + store.selectedScope = .combined + + #expect(store.payload.current.cost == 10) + #expect(store.payload.combined?.combined.cost == 42) + } + + @Test("multi-day combined selection uses local cache path") + func multiDayCombinedSelectionUsesLocalCachePath() { + let store = AppStore() + let days: Set = ["2026-06-01", "2026-06-02"] + store.selectedScope = .combined + store.selectedDays = days + + store.setCachedPayloadForTesting( + menubarPayload(cost: 18), + scope: .local, + period: .today, + provider: .all, + days: days, + fetchedAt: Date() + ) + store.setCachedPayloadForTesting( + menubarPayload(cost: 99, combined: combinedUsage(cost: 44)), + scope: .combined, + period: .today, + provider: .all, + days: days, + fetchedAt: Date() + ) + + #expect(store.activeScope == .local) + #expect(store.payload.current.cost == 18) + #expect(store.payload.combined == nil) + } + + @Test("combined failure state does not invalidate local badge payload") + func combinedFailureDoesNotInvalidateLocalBadgePayload() { + let store = AppStore() + store.setCachedPayloadForTesting( + menubarPayload(cost: 31), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + store.selectedScope = .combined + store.setLastErrorForTesting( + "timeout", + scope: .combined, + period: .today, + provider: .all + ) + + #expect(store.lastError == "timeout") + #expect(store.menubarPayload?.current.cost == 31) + #expect(!store.needsStatusPayloadRefresh) + #expect(store.payload.current.cost == 31) + #expect(store.payload.combined == nil) + } + + @Test("switching to combined resets selected provider to all") + func switchingToCombinedResetsSelectedProviderToAll() { + let store = AppStore() + store.suppressRefreshesForTesting() + store.selectedScope = .local + store.selectedProvider = .claude + + store.switchTo(scope: .combined) + + #expect(store.selectedScope == .combined) + #expect(store.selectedProvider == .all) + } + + @Test("daily budget warning is suppressed for combined scope") + func dailyBudgetWarningIsSuppressedForCombinedScope() { + let defaults = UserDefaults.standard + let previousDisplayMetric = defaults.object(forKey: "CodeBurnDisplayMetric") + let previousDailyBudget = defaults.object(forKey: "CodeBurnDailyBudget") + defer { + if let previousDisplayMetric { + defaults.set(previousDisplayMetric, forKey: "CodeBurnDisplayMetric") + } else { + defaults.removeObject(forKey: "CodeBurnDisplayMetric") + } + if let previousDailyBudget { + defaults.set(previousDailyBudget, forKey: "CodeBurnDailyBudget") + } else { + defaults.removeObject(forKey: "CodeBurnDailyBudget") + } + } + + let store = AppStore() + store.selectedScope = .local + store.selectedDays = [] + store.displayMetric = .cost + store.dailyBudget = 10 + store.setCachedPayloadForTesting( + menubarPayload(cost: 12.5), + scope: .local, + period: .today, + provider: .all, + fetchedAt: Date() + ) + + #expect(store.isOverDailyBudget) + #expect(store.shouldShowDailyBudgetWarning) + + store.selectedScope = .combined + + #expect(store.isOverDailyBudget) + #expect(!store.shouldShowDailyBudgetWarning) + } + @Test("missing today status payload needs status refresh") func missingTodayStatusPayloadNeedsStatusRefresh() { let store = AppStore() diff --git a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift index b98449d..ec95a90 100644 --- a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift @@ -1,7 +1,52 @@ -import XCTest +import Foundation +import Testing @testable import CodeBurnMenubar -final class DataClientProcessTests: XCTestCase { +@Suite("DataClient process") +struct DataClientProcessTests { + @Test("status argv local omits scope") + func statusSubcommandLocalOmitsScope() { + let args = DataClient.statusSubcommand( + period: .today, + provider: .claude, + includeOptimize: false, + scope: .local + ) + + #expect(!args.contains("--scope")) + #expect(value(after: "--provider", in: args) == "claude") + #expect(args.contains("--no-optimize")) + } + + @Test("status argv combined adds scope and forces provider all") + func statusSubcommandCombinedAddsScopeAndForcesAllProvider() { + let args = DataClient.statusSubcommand( + period: .today, + provider: .codex, + includeOptimize: true, + scope: .combined + ) + + #expect(value(after: "--scope", in: args) == "combined") + #expect(value(after: "--provider", in: args) == "all") + #expect(!args.contains("--no-optimize")) + } + + @Test("status argv combined multi-day coerces to local") + func statusSubcommandCombinedMultiDayCoercesToLocal() { + let args = DataClient.statusSubcommand( + period: .today, + days: ["2026-06-01", "2026-06-02"], + provider: .codex, + includeOptimize: false, + scope: .combined + ) + + #expect(!args.contains("--scope")) + #expect(value(after: "--provider", in: args) == "codex") + #expect(value(after: "--days", in: args) == "2026-06-01,2026-06-02") + } + /// Concurrency + timeout smoke test: launch more hung subprocesses than /// there are cooperative threads, all at once, with a short timeout, and /// assert every call returns once the timeout kills its sleep. @@ -13,7 +58,8 @@ final class DataClientProcessTests: XCTestCase { /// built up over ~2 days under the @MainActor refresh loop and is confirmed /// by the live `sample`, not by this test. Kept as a guard that the /// off-pool wait + timeout path stays correct under concurrency. - func testConcurrentTimedOutProcessesAllComplete() { + @Test("concurrent timed-out processes all complete") + func concurrentTimedOutProcessesAllComplete() { let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4 let done = DispatchSemaphore(value: 0) @@ -31,16 +77,16 @@ final class DataClientProcessTests: XCTestCase { done.signal() } - // Wait on the XCTest thread (a real thread, not the cooperative pool) so + // Wait on the test thread (a real thread, not the cooperative pool) so // the deadlock is detectable even when the pool is fully starved. let outcome = done.wait(timeout: .now() + 15) - XCTAssertEqual(outcome, .success, - "runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool") + #expect(outcome == .success, "runProcess deadlocked: \(count) concurrent CLIs starved the cooperative pool") } /// A decode failure surfaces the CLI's actual stdout/stderr so a stray banner /// on stdout (see #515) is self-diagnosing instead of an opaque "not valid JSON". - func testDecodeFailureSurfacesOutput() { + @Test("decode failure surfaces output") + func decodeFailureSurfacesOutput() { struct Boom: Error {} let failure = CLIDecodeFailure( underlying: Boom(), @@ -49,36 +95,39 @@ final class DataClientProcessTests: XCTestCase { stderr: "warn: x" ) let text = String(describing: failure) - XCTAssertTrue(text.contains("(node) banner"), "should include the stdout snippet") - XCTAssertTrue(text.contains("13 bytes"), "should include the stdout byte count") - XCTAssertTrue(text.contains("warn: x"), "should include stderr") + #expect(text.contains("(node) banner"), "should include the stdout snippet") + #expect(text.contains("13 bytes"), "should include the stdout byte count") + #expect(text.contains("warn: x"), "should include stderr") } /// Empty stdout is reported distinctly (the JSONDecoder-on-empty-Data case). - func testDecodeFailureWithEmptyStdout() { + @Test("decode failure with empty stdout") + func decodeFailureWithEmptyStdout() { struct Boom: Error {} let failure = CLIDecodeFailure(underlying: Boom(), stdoutByteCount: 0, stdoutSnippet: "", stderr: "") let text = String(describing: failure) - XCTAssertTrue(text.contains("0 bytes")) - XCTAssertTrue(text.contains("")) + #expect(text.contains("0 bytes")) + #expect(text.contains("")) } /// A normally-exiting process returns its real output and exit code through /// the off-pool wait path. - func testProcessReturnsOutputAndExitCode() async throws { + @Test("process returns output and exit code") + func processReturnsOutputAndExitCode() async throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/echo") process.arguments = ["hello"] let result = try await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo hello") - XCTAssertEqual(result.exitCode, 0) - XCTAssertEqual(String(data: result.stdout, encoding: .utf8), "hello\n") + #expect(result.exitCode == 0) + #expect(String(data: result.stdout, encoding: .utf8) == "hello\n") } /// Many NORMALLY-exiting processes, all at once, must every one complete /// through the terminationHandler wait path. Guards against the wait path /// leaking or wedging under concurrency (the production bug was the wait and /// its timeout sharing one queue that saturated under sustained load). - func testManyNormalProcessesAllComplete() async { + @Test("many normal processes all complete") + func manyNormalProcessesAllComplete() async { let count = 50 let codes = await withTaskGroup(of: Int32?.self) { group -> [Int32?] in for _ in 0.. 0) } } +private func value(after flag: String, in args: [String]) -> String? { + guard let index = args.firstIndex(of: flag), args.indices.contains(index + 1) else { + return nil + } + return args[index + 1] +} + private actor PeakCounter { private var current = 0 private(set) var peak = 0 diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift new file mode 100644 index 0000000..3b616cc --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPayloadCombinedTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +@Suite("MenubarPayload combined") +struct MenubarPayloadCombinedTests { + @Test("decodes combined block") + func decodesCombinedBlock() throws { + let json = combinedPayloadJSON() + + let payload = try JSONDecoder().decode(MenubarPayload.self, from: json) + + #expect(payload.combined?.perDevice.count == 2) + #expect(payload.combined?.perDevice.first?.id == "local-device") + #expect(payload.combined?.perDevice.first?.local == true) + #expect(payload.combined?.perDevice.last?.error == "offline") + #expect(payload.combined?.combined.cost == 4.5) + #expect(payload.combined?.combined.calls == 7) + #expect(payload.combined?.combined.deviceCount == 2) + #expect(payload.combined?.combined.reachableCount == 1) + } + + @Test("combined hero token total excludes cache tokens") + func combinedHeroTokenTotalExcludesCacheTokens() throws { + let payload = try JSONDecoder().decode(MenubarPayload.self, from: combinedPayloadJSON()) + let totals = HeroTotals(payload: payload, activeScope: .combined) + + #expect(payload.combined?.combined.totalTokens == 2000) + #expect(totals.inputTokens == 1000) + #expect(totals.outputTokens == 500) + #expect(totals.totalTokens == 1500) + } + + @Test("combined block is nil when absent") + func combinedBlockIsNilWhenAbsent() throws { + let json = Data(""" + { + "generated": "2026-06-24T00:00:00Z", + "current": { + "label": "Today", + "cost": 1.25, + "calls": 2, + "sessions": 1, + "inputTokens": 100, + "outputTokens": 50 + }, + "optimize": { + "findingCount": 0, + "savingsUSD": 0, + "topFindings": [] + }, + "history": { + "daily": [] + } + } + """.utf8) + + let payload = try JSONDecoder().decode(MenubarPayload.self, from: json) + + #expect(payload.combined == nil) + } +} + +private func combinedPayloadJSON() -> Data { + Data(""" + { + "generated": "2026-06-24T00:00:00Z", + "current": { + "label": "Today", + "cost": 1.25, + "calls": 2, + "sessions": 1, + "inputTokens": 100, + "outputTokens": 50 + }, + "optimize": { + "findingCount": 0, + "savingsUSD": 0, + "topFindings": [] + }, + "history": { + "daily": [] + }, + "combined": { + "perDevice": [ + { + "id": "local-device", + "name": "MacBook Pro", + "local": true, + "error": null, + "cost": 4.5, + "calls": 7, + "sessions": 3, + "inputTokens": 1000, + "outputTokens": 500, + "cacheCreateTokens": 200, + "cacheReadTokens": 300, + "totalTokens": 2000 + }, + { + "id": "remote-device", + "name": "Studio", + "local": false, + "error": "offline", + "cost": 0, + "calls": 0, + "sessions": 0, + "inputTokens": 0, + "outputTokens": 0, + "cacheCreateTokens": 0, + "cacheReadTokens": 0, + "totalTokens": 0 + } + ], + "combined": { + "cost": 4.5, + "calls": 7, + "sessions": 3, + "inputTokens": 1000, + "outputTokens": 500, + "cacheCreateTokens": 200, + "cacheReadTokens": 300, + "totalTokens": 2000, + "deviceCount": 2, + "reachableCount": 1 + } + } + } + """.utf8) +} diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift index 7b9f087..e52a987 100644 --- a/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/MenubarPeriodSettingsTests.swift @@ -1,48 +1,71 @@ import Foundation -import XCTest +import Testing @testable import CodeBurnMenubar -final class MenubarPeriodSettingsTests: XCTestCase { - func testSettingsPickerExposesRequestedPeriods() { - XCTAssertEqual(Period.menubarMetricCases, [.today, .sevenDays, .month, .all]) +@Suite("Menubar period settings") +struct MenubarPeriodSettingsTests { + @Test("settings picker exposes requested periods") + func settingsPickerExposesRequestedPeriods() { + #expect(Period.menubarMetricCases == [.today, .sevenDays, .month, .all]) } - func testDefaultsValuesMapToPeriods() { - XCTAssertEqual(Period(menubarDefaultsValue: "today"), .today) - XCTAssertEqual(Period(menubarDefaultsValue: "week"), .sevenDays) - XCTAssertEqual(Period(menubarDefaultsValue: "month"), .month) - XCTAssertEqual(Period(menubarDefaultsValue: "sixMonths"), .all) - XCTAssertEqual(Period(menubarDefaultsValue: "all"), .all) - XCTAssertEqual(Period(menubarDefaultsValue: "30days"), .today) - XCTAssertEqual(Period(menubarDefaultsValue: "bogus"), .today) - XCTAssertEqual(Period(menubarDefaultsValue: nil), .today) + @Test("defaults values map to periods") + func defaultsValuesMapToPeriods() { + #expect(Period(menubarDefaultsValue: "today") == .today) + #expect(Period(menubarDefaultsValue: "week") == .sevenDays) + #expect(Period(menubarDefaultsValue: "month") == .month) + #expect(Period(menubarDefaultsValue: "sixMonths") == .all) + #expect(Period(menubarDefaultsValue: "all") == .all) + #expect(Period(menubarDefaultsValue: "30days") == .today) + #expect(Period(menubarDefaultsValue: "bogus") == .today) + #expect(Period(menubarDefaultsValue: nil) == .today) } - func testPeriodsPersistCanonicalDefaultsValues() throws { + @Test("periods persist canonical defaults values") + func periodsPersistCanonicalDefaultsValues() { let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" - let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + let defaults = UserDefaults(suiteName: suiteName)! defer { defaults.removePersistentDomain(forName: suiteName) } Period.sevenDays.persistAsMenubarDefault(defaults: defaults) - XCTAssertEqual(defaults.string(forKey: "CodeBurnMenubarPeriod"), "week") - XCTAssertEqual(Period.savedMenubarPeriod(defaults: defaults), .sevenDays) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "week") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .sevenDays) Period.all.persistAsMenubarDefault(defaults: defaults) - XCTAssertEqual(defaults.string(forKey: "CodeBurnMenubarPeriod"), "sixMonths") - XCTAssertEqual(Period.savedMenubarPeriod(defaults: defaults), .all) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "sixMonths") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .all) Period.thirtyDays.persistAsMenubarDefault(defaults: defaults) - XCTAssertEqual(defaults.string(forKey: "CodeBurnMenubarPeriod"), "today") - XCTAssertEqual(Period.savedMenubarPeriod(defaults: defaults), .today) + #expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "today") + #expect(Period.savedMenubarPeriod(defaults: defaults) == .today) } - func testNonTodayPeriodsRenderCompactAndRegularSuffixes() { - XCTAssertEqual(Period.today.menubarSuffix(compact: false), "") - XCTAssertEqual(Period.sevenDays.menubarSuffix(compact: false), " / wk") - XCTAssertEqual(Period.month.menubarSuffix(compact: false), " / mo") - XCTAssertEqual(Period.all.menubarSuffix(compact: false), " / 6mo") - XCTAssertEqual(Period.sevenDays.menubarSuffix(compact: true), "/wk") - XCTAssertEqual(Period.month.menubarSuffix(compact: true), "/mo") - XCTAssertEqual(Period.all.menubarSuffix(compact: true), "/6mo") + @Test("menubar scope persistence defaults to local and round-trips") + func menubarScopePersistenceDefaultsToLocalAndRoundTrips() { + let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + #expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local) + + MenubarScope.combined.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarScope") == "combined") + #expect(MenubarScope.savedMenubarScope(defaults: defaults) == .combined) + + MenubarScope.local.persistAsMenubarDefault(defaults: defaults) + #expect(defaults.string(forKey: "CodeBurnMenubarScope") == "local") + #expect(MenubarScope.savedMenubarScope(defaults: defaults) == .local) + #expect(MenubarScope(menubarDefaultsValue: "bogus") == .local) + } + + @Test("non-today periods render compact and regular suffixes") + func nonTodayPeriodsRenderCompactAndRegularSuffixes() { + #expect(Period.today.menubarSuffix(compact: false) == "") + #expect(Period.sevenDays.menubarSuffix(compact: false) == " / wk") + #expect(Period.month.menubarSuffix(compact: false) == " / mo") + #expect(Period.all.menubarSuffix(compact: false) == " / 6mo") + #expect(Period.sevenDays.menubarSuffix(compact: true) == "/wk") + #expect(Period.month.menubarSuffix(compact: true) == "/mo") + #expect(Period.all.menubarSuffix(compact: true) == "/6mo") } } diff --git a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift index 1d347b1..8c769ab 100644 --- a/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/MenubarStatusCacheTests.swift @@ -24,7 +24,8 @@ struct MenubarStatusCacheTests { tools: [], skills: [], subagents: [], mcpServers: [] ), optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []), - history: HistoryBlock(daily: []) + history: HistoryBlock(daily: []), + combined: nil ) return try! JSONEncoder().encode(p) }