mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-23 07:25:16 +00:00
Under combined scope the badge falls back to the local figure whenever a paired device doesn't report (asleep / off-network), which read as a glitch. Append a dimmed "reachable/total" marker (e.g. "$95.94 /mo · 1/2") and a matching tooltip so the reduced total is legibly "peer unreachable" instead. The marker clears the instant every paired device reports again. Adds AppStore.menubarBadgeDeviceShortfall plus tests.
496 lines
18 KiB
Swift
496 lines
18 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodeBurnMenubar
|
|
|
|
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 claudeConfigSelector(selectedId: String? = nil) -> ClaudeConfigSelector {
|
|
ClaudeConfigSelector(
|
|
selectedId: selectedId,
|
|
options: [
|
|
ClaudeConfigOption(id: "claude-config:work", label: "claude-work", path: "/tmp/claude-work"),
|
|
ClaudeConfigOption(id: "claude-config:personal", label: "claude-personal", path: "/tmp/claude-personal")
|
|
]
|
|
)
|
|
}
|
|
|
|
private func menubarPayload(cost: Double,
|
|
combined: CombinedUsage? = nil,
|
|
claudeConfigs: ClaudeConfigSelector? = nil) -> MenubarPayload {
|
|
MenubarPayload(
|
|
generated: "test",
|
|
current: CurrentBlock(
|
|
label: "Today",
|
|
cost: cost,
|
|
calls: 1,
|
|
sessions: 1,
|
|
oneShotRate: nil,
|
|
inputTokens: 1,
|
|
outputTokens: 1,
|
|
cacheHitPercent: 0,
|
|
codexCredits: nil,
|
|
topActivities: [],
|
|
topModels: [],
|
|
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
|
|
providers: ["claude": cost],
|
|
topProjects: [],
|
|
modelEfficiency: [],
|
|
topSessions: [],
|
|
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
|
|
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
|
|
tools: [],
|
|
skills: [],
|
|
subagents: [],
|
|
mcpServers: []
|
|
),
|
|
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
|
|
history: HistoryBlock(daily: []),
|
|
combined: combined,
|
|
claudeConfigs: claudeConfigs
|
|
)
|
|
}
|
|
|
|
@Suite("AppStore refresh recovery")
|
|
@MainActor
|
|
struct AppStoreRefreshRecoveryTests {
|
|
@Test("stale visible payload triggers hard recovery without clearing cache")
|
|
func stalePayloadTriggersHardRecoveryWithoutClearingCache() {
|
|
let store = AppStore()
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 92.33),
|
|
period: .today,
|
|
provider: .all,
|
|
fetchedAt: Date().addingTimeInterval(-180)
|
|
)
|
|
|
|
#expect(store.todayPayload?.current.cost == 92.33)
|
|
#expect(store.needsInteractivePayloadRefresh)
|
|
#expect(store.needsStatusPayloadRefresh)
|
|
#expect(store.hasStaleInteractivePayload)
|
|
#expect(store.shouldResetInteractiveRefreshPipeline)
|
|
|
|
store.resetRefreshState(clearCache: false)
|
|
|
|
#expect(store.todayPayload?.current.cost == 92.33)
|
|
}
|
|
|
|
@Test("fresh visible payload does not trigger hard recovery")
|
|
func freshPayloadDoesNotTriggerHardRecovery() {
|
|
let store = AppStore()
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 164.06),
|
|
period: .today,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
|
|
#expect(!store.needsInteractivePayloadRefresh)
|
|
#expect(!store.needsStatusPayloadRefresh)
|
|
#expect(!store.hasStaleInteractivePayload)
|
|
#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<String> = ["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("menubar badge shows the combined total under combined scope")
|
|
func menubarBadgeShowsCombinedTotal() {
|
|
let store = AppStore()
|
|
store.suppressRefreshesForTesting()
|
|
let period = store.menubarPeriod
|
|
// Local badge figure and a higher cross-device combined total, both for
|
|
// the badge's period.
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 30),
|
|
scope: .local,
|
|
period: period,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 30, combined: combinedUsage(cost: 75)),
|
|
scope: .combined,
|
|
period: period,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
|
|
// Local scope: no combined total, so the badge renders the local figure.
|
|
store.selectedScope = .local
|
|
#expect(store.menubarBadgeCombined == nil)
|
|
|
|
// Combined scope: the badge total is the cross-device aggregate ($75),
|
|
// not the local $30 — this is the fix for the badge trailing the popover.
|
|
store.selectedScope = .combined
|
|
#expect(store.menubarBadgeCombined?.cost == 75)
|
|
}
|
|
|
|
@Test("badge reports a device shortfall when a paired peer is unreachable")
|
|
func menubarBadgeReportsDeviceShortfall() {
|
|
let store = AppStore()
|
|
store.suppressRefreshesForTesting()
|
|
let period = store.menubarPeriod
|
|
// Combined payload where only 1 of 2 paired devices reported this cycle
|
|
// (the peer is asleep/off-network), so the aggregate is degraded to local.
|
|
let degraded = CombinedUsage(
|
|
perDevice: [],
|
|
combined: CombinedUsageTotals(
|
|
cost: 30,
|
|
calls: 3,
|
|
sessions: 2,
|
|
inputTokens: 100,
|
|
outputTokens: 50,
|
|
cacheCreateTokens: 10,
|
|
cacheReadTokens: 20,
|
|
totalTokens: 180,
|
|
deviceCount: 2,
|
|
reachableCount: 1
|
|
)
|
|
)
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 30, combined: degraded),
|
|
scope: .combined,
|
|
period: period,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
store.selectedScope = .combined
|
|
|
|
let shortfall = store.menubarBadgeDeviceShortfall
|
|
#expect(shortfall?.reachable == 1)
|
|
#expect(shortfall?.total == 2)
|
|
}
|
|
|
|
@Test("badge reports no shortfall when every paired device reports")
|
|
func menubarBadgeNoShortfallWhenAllReachable() {
|
|
let store = AppStore()
|
|
store.suppressRefreshesForTesting()
|
|
let period = store.menubarPeriod
|
|
// combinedUsage() carries deviceCount == reachableCount == 1.
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 30, combined: combinedUsage(cost: 30)),
|
|
scope: .combined,
|
|
period: period,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
store.selectedScope = .combined
|
|
#expect(store.menubarBadgeDeviceShortfall == nil)
|
|
|
|
// Local scope never reports a shortfall.
|
|
store.selectedScope = .local
|
|
#expect(store.menubarBadgeDeviceShortfall == nil)
|
|
}
|
|
|
|
@Test("menubar badge falls back to local when no combined payload is cached")
|
|
func menubarBadgeFallsBackWhenCombinedMissing() {
|
|
let store = AppStore()
|
|
store.suppressRefreshesForTesting()
|
|
let period = store.menubarPeriod
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 30),
|
|
scope: .local,
|
|
period: period,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
// Combined scope selected but no combined payload cached yet (cold cache
|
|
// or an unreachable peer): the badge must fall back to the local figure.
|
|
store.selectedScope = .combined
|
|
#expect(store.menubarBadgeCombined == 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("selected Claude config partitions payload cache")
|
|
func selectedClaudeConfigPartitionsPayloadCache() {
|
|
let store = AppStore()
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 10, claudeConfigs: claudeConfigSelector()),
|
|
scope: .local,
|
|
period: .today,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 4, claudeConfigs: claudeConfigSelector(selectedId: "claude-config:work")),
|
|
scope: .local,
|
|
period: .today,
|
|
provider: .all,
|
|
claudeConfigSourceId: "claude-config:work",
|
|
fetchedAt: Date()
|
|
)
|
|
|
|
#expect(store.payload.current.cost == 10)
|
|
|
|
store.selectedClaudeConfigSourceId = "claude-config:work"
|
|
|
|
#expect(store.payload.current.cost == 4)
|
|
#expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all)?.current.cost == 10)
|
|
#expect(store.cachedPayloadForTesting(scope: .local, period: .today, provider: .all, claudeConfigSourceId: "claude-config:work")?.current.cost == 4)
|
|
}
|
|
|
|
@Test("Claude config selector is hidden until multiple configs are available")
|
|
func claudeConfigSelectorVisibilityRequiresMultipleConfigs() {
|
|
let store = AppStore()
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 1),
|
|
scope: .local,
|
|
period: .today,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
#expect(!store.shouldShowClaudeConfigSelector)
|
|
|
|
store.setCachedPayloadForTesting(
|
|
menubarPayload(cost: 2, claudeConfigs: claudeConfigSelector()),
|
|
scope: .local,
|
|
period: .today,
|
|
provider: .all,
|
|
fetchedAt: Date()
|
|
)
|
|
|
|
#expect(store.shouldShowClaudeConfigSelector)
|
|
#expect(store.claudeConfigOptions.map(\.label) == ["claude-work", "claude-personal"])
|
|
}
|
|
|
|
@Test("selecting Claude config resets provider and combined scope")
|
|
func selectingClaudeConfigResetsProviderAndCombinedScope() {
|
|
let store = AppStore()
|
|
store.suppressRefreshesForTesting()
|
|
store.selectedScope = .combined
|
|
store.selectedProvider = .codex
|
|
|
|
store.switchTo(claudeConfigSourceId: "claude-config:work")
|
|
|
|
#expect(store.selectedClaudeConfigSourceId == "claude-config:work")
|
|
#expect(store.selectedScope == .local)
|
|
#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()
|
|
|
|
#expect(store.todayPayload == nil)
|
|
#expect(store.needsStatusPayloadRefresh)
|
|
}
|
|
|
|
@Test("missing unattempted payload triggers hard recovery")
|
|
func missingUnattemptedPayloadTriggersHardRecovery() {
|
|
let store = AppStore()
|
|
|
|
#expect(!store.hasCachedData)
|
|
#expect(!store.hasAttemptedCurrentKeyLoad)
|
|
#expect(store.needsInteractivePayloadRefresh)
|
|
#expect(store.hasMissingInteractivePayloadWithoutAttempt)
|
|
#expect(store.shouldResetInteractiveRefreshPipeline)
|
|
}
|
|
|
|
@Test("orphaned stale in-flight entry does not block stuck-loading recovery")
|
|
func staleInFlightDoesNotBlockRecovery() {
|
|
let store = AppStore()
|
|
// A quiet refresh torn down across sleep/wake can leave an in-flight
|
|
// entry behind for the current key with no cache and no active loading
|
|
// counter, far older than the watchdog window. Recovery must clear it
|
|
// and proceed instead of bailing on the in-flight guard forever.
|
|
store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date().addingTimeInterval(-3600))
|
|
|
|
#expect(store.isInFlightForTesting(period: .today, provider: .all))
|
|
|
|
let canRecover = store.prepareStuckLoadingRecovery()
|
|
|
|
#expect(canRecover)
|
|
#expect(!store.isInFlightForTesting(period: .today, provider: .all))
|
|
}
|
|
|
|
@Test("healthy in-flight fetch is not killed by recovery")
|
|
func healthyInFlightFetchSurvivesRecovery() {
|
|
let store = AppStore()
|
|
store.seedInFlightForTesting(period: .today, provider: .all, insertedAt: Date())
|
|
|
|
let canRecover = store.prepareStuckLoadingRecovery()
|
|
|
|
#expect(!canRecover)
|
|
#expect(store.isInFlightForTesting(period: .today, provider: .all))
|
|
}
|
|
|
|
@Test("prepareStuckLoadingRecovery clears stale loading bookkeeping for the current key")
|
|
func popoverRecoveryClearsStuckLoading() {
|
|
let store = AppStore()
|
|
// Seed an orphaned in-flight entry older than the 60s watchdog so the
|
|
// stale-clear path runs, mimicking a fetch torn down across sleep/wake.
|
|
store.seedInFlightForTesting(
|
|
period: .today,
|
|
provider: .all,
|
|
insertedAt: Date().addingTimeInterval(-120)
|
|
)
|
|
#expect(store.isInFlightForTesting(period: .today, provider: .all))
|
|
|
|
let willFetch = store.prepareStuckLoadingRecovery()
|
|
|
|
#expect(willFetch)
|
|
#expect(!store.isInFlightForTesting(period: .today, provider: .all))
|
|
}
|
|
|
|
}
|