chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
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("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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("AppVersion")
|
||||
struct AppVersionTests {
|
||||
@Test("display avoids duplicate v prefix")
|
||||
func displayAvoidsDuplicatePrefix() {
|
||||
#expect(AppVersion.display("0.9.8") == "v0.9.8")
|
||||
#expect(AppVersion.display("v0.9.8") == "v0.9.8")
|
||||
#expect(AppVersion.display("mac-v0.9.8") == "v0.9.8")
|
||||
}
|
||||
|
||||
@Test("bundle metadata stores unprefixed semver")
|
||||
func normalizeBundleVersion() {
|
||||
#expect(AppVersion.normalize("v0.9.8") == "0.9.8")
|
||||
#expect(AppVersion.normalize("mac-v0.9.8") == "0.9.8")
|
||||
#expect(AppVersion.normalize("dev") == "dev")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("CLI Devin config", .serialized)
|
||||
struct CLIDevinConfigTests {
|
||||
private func withTemporaryStore(_ body: (URL, CodeburnCLIConfigStore) throws -> Void) throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("codeburn-devin-config-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
try body(root, CodeburnCLIConfigStore(homeDirectory: root.path))
|
||||
}
|
||||
|
||||
private func configURL(in home: URL) -> URL {
|
||||
home
|
||||
.appendingPathComponent(".config", isDirectory: true)
|
||||
.appendingPathComponent("codeburn", isDirectory: true)
|
||||
.appendingPathComponent("config.json")
|
||||
}
|
||||
|
||||
private func writeConfig(_ object: [String: Any], in home: URL) throws {
|
||||
let url = configURL(in: home)
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: url)
|
||||
}
|
||||
|
||||
private func readConfig(in home: URL) throws -> [String: Any] {
|
||||
let data = try Data(contentsOf: configURL(in: home))
|
||||
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
|
||||
@Test("missing config has no ACU rate")
|
||||
func missingConfigHasNoRate() throws {
|
||||
try withTemporaryStore { _, store in
|
||||
#expect(store.loadDevinAcuUsdRate() == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("persists and loads ACU rate")
|
||||
func persistsAndLoadsRate() throws {
|
||||
try withTemporaryStore { _, store in
|
||||
store.persistDevinAcuUsdRate(2.25)
|
||||
|
||||
#expect(store.loadDevinAcuUsdRate() == 2.25)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("preserves existing config while adding Devin rate")
|
||||
func preservesExistingConfig() throws {
|
||||
try withTemporaryStore { home, store in
|
||||
try writeConfig([
|
||||
"currency": [
|
||||
"code": "EUR",
|
||||
"symbol": "\u{20AC}"
|
||||
]
|
||||
], in: home)
|
||||
|
||||
store.persistDevinAcuUsdRate(3.5)
|
||||
|
||||
let json = try readConfig(in: home)
|
||||
let currency = try #require(json["currency"] as? [String: Any])
|
||||
let devin = try #require(json["devin"] as? [String: Any])
|
||||
#expect(currency["code"] as? String == "EUR")
|
||||
#expect(devin["acuUsdRate"] as? Double == 3.5)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("ignores invalid rates")
|
||||
func ignoresInvalidRates() throws {
|
||||
try withTemporaryStore { _, store in
|
||||
store.persistDevinAcuUsdRate(1.75)
|
||||
store.persistDevinAcuUsdRate(0)
|
||||
store.persistDevinAcuUsdRate(-2)
|
||||
store.persistDevinAcuUsdRate(.infinity)
|
||||
|
||||
#expect(store.loadDevinAcuUsdRate() == 1.75)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("loads only positive finite numeric rates")
|
||||
func loadsOnlyPositiveFiniteNumericRates() throws {
|
||||
try withTemporaryStore { home, store in
|
||||
try writeConfig(["devin": ["acuUsdRate": 0]], in: home)
|
||||
#expect(store.loadDevinAcuUsdRate() == nil)
|
||||
|
||||
try writeConfig(["devin": ["acuUsdRate": "2.25"]], in: home)
|
||||
#expect(store.loadDevinAcuUsdRate() == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
private let now = Date(timeIntervalSince1970: 1_734_000_000)
|
||||
|
||||
private func snap(_ percent: Double, _ tokens: Double, ageDays: Double = 0) -> CapacitySnapshot {
|
||||
CapacitySnapshot(
|
||||
percent: percent,
|
||||
effectiveTokens: tokens,
|
||||
capturedAt: now.addingTimeInterval(-ageDays * 86400)
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- gating")
|
||||
struct CapacityEstimatorGatingTests {
|
||||
@Test("returns nil with no snapshots")
|
||||
func emptyReturnsNil() {
|
||||
#expect(CapacityEstimator.estimate([], asOf: now) == nil)
|
||||
}
|
||||
|
||||
@Test("returns nil with fewer than 5 snapshots")
|
||||
func tooFewReturnsNil() {
|
||||
let snaps = (1...4).map { snap(Double($0 * 10), Double($0) * 100_000) }
|
||||
#expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
|
||||
}
|
||||
|
||||
@Test("returns nil when percent range is below 15 points")
|
||||
func tooNarrowReturnsNil() {
|
||||
let snaps = [
|
||||
snap(40, 4_000_000),
|
||||
snap(42, 4_200_000),
|
||||
snap(44, 4_400_000),
|
||||
snap(46, 4_600_000),
|
||||
snap(48, 4_800_000),
|
||||
snap(50, 5_000_000),
|
||||
]
|
||||
#expect(CapacityEstimator.estimate(snaps, asOf: now) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- recovery")
|
||||
struct CapacityEstimatorRecoveryTests {
|
||||
@Test("recovers capacity from 10 noise-free snapshots within 0.5%")
|
||||
func recoverFromCleanData() {
|
||||
let trueCapacity: Double = 10_000_000
|
||||
let percents = [5.0, 12, 20, 28, 35, 47, 55, 68, 80, 92]
|
||||
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.capacity > trueCapacity * 0.995)
|
||||
#expect(est!.capacity < trueCapacity * 1.005)
|
||||
// 10 perfect samples is below the solid sample threshold (15) but easily medium.
|
||||
#expect(est!.confidence == .medium || est!.confidence == .solid)
|
||||
}
|
||||
|
||||
@Test("recovers capacity within 5% from 30 noisy snapshots")
|
||||
func recoverFromNoisyData() {
|
||||
let trueCapacity: Double = 8_000_000
|
||||
var rng = LinearCongruentialGenerator(seed: 42)
|
||||
let snaps: [CapacitySnapshot] = (0..<30).map { i in
|
||||
let p = 5.0 + Double(i) * 3.0 // 5..92, spanning enough
|
||||
let noise = (rng.nextDouble() - 0.5) * 0.10 // ±5%
|
||||
let tokens = (p / 100) * trueCapacity * (1 + noise)
|
||||
return snap(p, tokens)
|
||||
}
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
let ratio = est!.capacity / trueCapacity
|
||||
#expect(ratio > 0.95 && ratio < 1.05)
|
||||
#expect(est!.confidence == .solid || est!.confidence == .medium)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- confidence tiers")
|
||||
struct CapacityEstimatorConfidenceTests {
|
||||
@Test("six clean snapshots span sufficient range -> at least medium")
|
||||
func sixCleanSnapshotsMedium() {
|
||||
let trueCapacity: Double = 5_000_000
|
||||
let percents = [5.0, 18, 32, 51, 70, 88]
|
||||
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.confidence == .medium || est!.confidence == .solid)
|
||||
}
|
||||
|
||||
@Test("noisy small-sample data falls to low confidence")
|
||||
func noisySmallSampleLow() {
|
||||
let trueCapacity: Double = 5_000_000
|
||||
var rng = LinearCongruentialGenerator(seed: 7)
|
||||
let percents = [5.0, 22, 40, 60, 80, 95]
|
||||
let snaps: [CapacitySnapshot] = percents.map { p in
|
||||
let noise = (rng.nextDouble() - 0.5) * 1.6 // ±80% noise -> drops R^2 below medium gate
|
||||
return snap(p, p / 100 * trueCapacity * (1 + noise))
|
||||
}
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.confidence == .low)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- recency weighting")
|
||||
struct CapacityEstimatorRecencyTests {
|
||||
@Test("recent snapshots dominate over old ones with different capacity")
|
||||
func recencyShiftsEstimate() {
|
||||
// Old data: capacity = 5M (45 days ago)
|
||||
// New data: capacity = 10M (today)
|
||||
// With 30-day half-life, recent data should win.
|
||||
let oldSnaps = (0..<10).map { i -> CapacitySnapshot in
|
||||
let p = 10.0 + Double(i) * 8
|
||||
return snap(p, p / 100 * 5_000_000, ageDays: 45)
|
||||
}
|
||||
let newSnaps = (0..<10).map { i -> CapacitySnapshot in
|
||||
let p = 10.0 + Double(i) * 8
|
||||
return snap(p, p / 100 * 10_000_000, ageDays: 1)
|
||||
}
|
||||
let est = CapacityEstimator.estimate(oldSnaps + newSnaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
// Recent capacity is 10M; estimate should be closer to 10M than 5M.
|
||||
#expect(est!.capacity > 7_500_000)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("CapacityEstimator -- non-linearity")
|
||||
struct CapacityEstimatorNonLinearityTests {
|
||||
@Test("flags non-linearity when residuals show systematic sign pattern")
|
||||
func detectsKneePattern() {
|
||||
// Data follows a knee: linear up to 60%, then flatter (Anthropic capping).
|
||||
let snaps: [CapacitySnapshot] = (0..<20).map { i in
|
||||
let p = 5.0 + Double(i) * 5
|
||||
let tokens: Double = p < 60 ? p / 100 * 8_000_000 : 0.6 * 8_000_000 + (p - 60) / 100 * 4_000_000
|
||||
return snap(p, tokens)
|
||||
}
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.nonLinearityWarning == true)
|
||||
}
|
||||
|
||||
@Test("does not flag clean linear data")
|
||||
func cleanLinearNoFlag() {
|
||||
let trueCapacity: Double = 6_000_000
|
||||
let percents = stride(from: 5.0, to: 95.0, by: 5.0).map { $0 }
|
||||
let snaps = percents.map { p in snap(p, p / 100 * trueCapacity) }
|
||||
let est = CapacityEstimator.estimate(snaps, asOf: now)
|
||||
#expect(est != nil)
|
||||
#expect(est!.nonLinearityWarning == false)
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight deterministic RNG for reproducible noise in tests.
|
||||
struct LinearCongruentialGenerator {
|
||||
private var state: UInt64
|
||||
init(seed: UInt64) { self.state = seed }
|
||||
mutating func nextDouble() -> Double {
|
||||
state = state &* 6364136223846793005 &+ 1442695040888963407
|
||||
return Double(state >> 11) / Double(1 << 53)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Claude usage response parsing")
|
||||
struct ClaudeSubscriptionParsingTests {
|
||||
// Shape captured from the live oauth/usage endpoint (2026-07): named
|
||||
// windows plus a `limits` array carrying model-scoped weekly buckets.
|
||||
private let liveShape = """
|
||||
{
|
||||
"five_hour": { "utilization": 13.0, "resets_at": "2026-07-02T22:09:59.599633+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null },
|
||||
"seven_day": { "utilization": 63.0, "resets_at": "2026-07-03T06:59:59.599658+00:00", "limit_dollars": null, "used_dollars": null, "remaining_dollars": null },
|
||||
"seven_day_oauth_apps": null,
|
||||
"seven_day_opus": null,
|
||||
"seven_day_sonnet": null,
|
||||
"extra_usage": { "is_enabled": false },
|
||||
"limits": [
|
||||
{ "kind": "session", "group": "session", "percent": 13, "severity": "normal", "resets_at": "2026-07-02T22:09:59.456907+00:00", "scope": null, "is_active": false },
|
||||
{ "kind": "weekly_all", "group": "weekly", "percent": 63, "severity": "normal", "resets_at": "2026-07-03T06:59:59.456926+00:00", "scope": null, "is_active": false },
|
||||
{ "kind": "weekly_scoped", "group": "weekly", "percent": 94, "severity": "critical", "resets_at": "2026-07-03T06:59:59.457220+00:00", "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, "is_active": true }
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
@Test("model-scoped weekly bucket surfaces with its display name")
|
||||
func scopedWeeklyParsed() throws {
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x")
|
||||
#expect(usage.scopedWeekly.count == 1)
|
||||
let fable = try #require(usage.scopedWeekly.first)
|
||||
#expect(fable.label == "Fable")
|
||||
#expect(fable.percent == 94)
|
||||
#expect(fable.resetsAt != nil)
|
||||
}
|
||||
|
||||
@Test("named windows still map alongside limits")
|
||||
func namedWindowsUnaffected() throws {
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: "max_20x")
|
||||
#expect(usage.fiveHourPercent == 13.0)
|
||||
#expect(usage.sevenDayPercent == 63.0)
|
||||
#expect(usage.sevenDayOpusPercent == nil)
|
||||
#expect(usage.sevenDaySonnetPercent == nil)
|
||||
#expect(usage.tier == .max20x)
|
||||
}
|
||||
|
||||
@Test("session and weekly_all limits are not duplicated as scoped rows")
|
||||
func unscopedKindsSkipped() throws {
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(liveShape.utf8), rawTier: nil)
|
||||
#expect(!usage.scopedWeekly.contains { $0.percent == 13 || $0.percent == 63 })
|
||||
}
|
||||
|
||||
@Test("response without a limits array parses with no scoped windows")
|
||||
func missingLimitsIsBackCompat() throws {
|
||||
let old = """
|
||||
{
|
||||
"five_hour": { "utilization": 40.0, "resets_at": "2026-07-02T22:00:00+00:00" },
|
||||
"seven_day": { "utilization": 12.5, "resets_at": "2026-07-03T07:00:00+00:00" }
|
||||
}
|
||||
"""
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(old.utf8), rawTier: "pro")
|
||||
#expect(usage.scopedWeekly.isEmpty)
|
||||
#expect(usage.fiveHourPercent == 40.0)
|
||||
#expect(usage.tier == .pro)
|
||||
}
|
||||
|
||||
@Test("weekly_scoped without a model display name is skipped")
|
||||
func scopedWithoutNameSkipped() throws {
|
||||
let body = """
|
||||
{
|
||||
"limits": [
|
||||
{ "kind": "weekly_scoped", "percent": 50, "resets_at": "2026-07-03T07:00:00+00:00", "scope": { "model": null } }
|
||||
]
|
||||
}
|
||||
"""
|
||||
let usage = try ClaudeSubscriptionService.parseUsage(Data(body.utf8), rawTier: nil)
|
||||
#expect(usage.scopedWeekly.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Codex terminal failure classification")
|
||||
struct CodexTerminalFailureTests {
|
||||
@Test("expired refresh token (4xx) is terminal")
|
||||
func expiredRefreshTokenIsTerminal() {
|
||||
let err = CodexCredentialStore.StoreError.refreshHTTPError(400, "invalid_grant: refresh_token_expired")
|
||||
#expect(err.isTerminal)
|
||||
}
|
||||
|
||||
@Test("missing refresh token is terminal")
|
||||
func missingRefreshTokenIsTerminal() {
|
||||
#expect(CodexCredentialStore.StoreError.noRefreshToken.isTerminal)
|
||||
}
|
||||
|
||||
@Test("a 5xx refresh error is not terminal")
|
||||
func serverErrorIsNotTerminal() {
|
||||
let err = CodexCredentialStore.StoreError.refreshHTTPError(503, "service unavailable")
|
||||
#expect(!err.isTerminal)
|
||||
}
|
||||
|
||||
@Test("a network error is not terminal")
|
||||
func networkErrorIsNotTerminal() {
|
||||
let err = CodexCredentialStore.StoreError.refreshNetworkError(URLError(.timedOut))
|
||||
#expect(!err.isTerminal)
|
||||
}
|
||||
|
||||
@Test("FetchError wrapping a terminal credential error is terminal")
|
||||
func fetchErrorPropagatesTerminal() {
|
||||
let store = CodexCredentialStore.StoreError.refreshHTTPError(401, "invalid_grant")
|
||||
let fetch = CodexSubscriptionService.FetchError.credential(store)
|
||||
#expect(fetch.isTerminal)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
private let heatmapNow: Date = {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
return calendar.date(from: DateComponents(year: 2026, month: 6, day: 3, hour: 12))!
|
||||
}()
|
||||
|
||||
private let heatmapCalendar: Calendar = {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
return calendar
|
||||
}()
|
||||
|
||||
private let heatmapFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func historyEntry(
|
||||
_ date: String,
|
||||
cost: Double,
|
||||
calls: Int = 1,
|
||||
inputTokens: Int = 100,
|
||||
outputTokens: Int = 20
|
||||
) -> DailyHistoryEntry {
|
||||
DailyHistoryEntry(
|
||||
date: date,
|
||||
cost: cost,
|
||||
savingsUSD: 0,
|
||||
calls: calls,
|
||||
inputTokens: inputTokens,
|
||||
outputTokens: outputTokens,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
topModels: []
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("Contribution heatmap")
|
||||
struct ContributionHeatmapTests {
|
||||
@Test("builds Monday-start weeks ending with the current week")
|
||||
func buildsMondayStartWeeks() {
|
||||
let weeks = buildContributionWeeks(
|
||||
from: [historyEntry("2026-06-03", cost: 10)],
|
||||
weekCount: 2,
|
||||
now: heatmapNow,
|
||||
calendar: heatmapCalendar,
|
||||
formatter: heatmapFormatter
|
||||
)
|
||||
|
||||
#expect(weeks.map(\.startDate) == ["2026-05-25", "2026-06-01"])
|
||||
#expect(weeks.allSatisfy { $0.days.count == 7 })
|
||||
#expect(weeks[1].days[0].date == "2026-06-01")
|
||||
#expect(weeks[1].days[2].date == "2026-06-03")
|
||||
#expect(weeks[1].days[2].isToday)
|
||||
}
|
||||
|
||||
@Test("marks future cells after today")
|
||||
func marksFutureCells() {
|
||||
let weeks = buildContributionWeeks(
|
||||
from: [
|
||||
historyEntry("2026-06-03", cost: 10),
|
||||
historyEntry("2026-06-04", cost: 99),
|
||||
],
|
||||
weekCount: 1,
|
||||
now: heatmapNow,
|
||||
calendar: heatmapCalendar,
|
||||
formatter: heatmapFormatter
|
||||
)
|
||||
|
||||
let currentWeek = weeks[0].days
|
||||
#expect(currentWeek[2].date == "2026-06-03")
|
||||
#expect(currentWeek[2].cost == 10)
|
||||
#expect(!currentWeek[2].isFuture)
|
||||
#expect(currentWeek[3].date == "2026-06-04")
|
||||
#expect(currentWeek[3].cost == 0)
|
||||
#expect(currentWeek[3].isFuture)
|
||||
}
|
||||
|
||||
@Test("maps costs to four nonzero intensity levels")
|
||||
func mapsIntensityLevels() {
|
||||
#expect(contributionLevel(value: 0, maxValue: 100) == 0)
|
||||
#expect(contributionLevel(value: 10, maxValue: 100) == 1)
|
||||
#expect(contributionLevel(value: 25, maxValue: 100) == 2)
|
||||
#expect(contributionLevel(value: 50, maxValue: 100) == 3)
|
||||
#expect(contributionLevel(value: 75, maxValue: 100) == 4)
|
||||
#expect(contributionLevel(value: 100, maxValue: 100) == 4)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test("computes total, active days, average, and current streak")
|
||||
func computesStats() {
|
||||
let weeks = buildContributionWeeks(
|
||||
from: [
|
||||
historyEntry("2026-06-01", cost: 3),
|
||||
historyEntry("2026-06-02", cost: 0),
|
||||
historyEntry("2026-06-03", cost: 9),
|
||||
],
|
||||
weekCount: 1,
|
||||
now: heatmapNow,
|
||||
calendar: heatmapCalendar,
|
||||
formatter: heatmapFormatter
|
||||
)
|
||||
|
||||
let stats = computeContributionStats(weeks: weeks)
|
||||
#expect(stats.total == 12)
|
||||
#expect(stats.activeDays == 2)
|
||||
#expect(stats.avgActive == 6)
|
||||
#expect(stats.currentStreak == 1)
|
||||
#expect(stats.peakLabel.contains("Jun 3"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@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")
|
||||
}
|
||||
|
||||
@Test("status argv local includes Claude config source")
|
||||
func statusSubcommandLocalIncludesClaudeConfigSource() {
|
||||
let args = DataClient.statusSubcommand(
|
||||
period: .today,
|
||||
provider: .all,
|
||||
includeOptimize: false,
|
||||
scope: .local,
|
||||
claudeConfigSourceId: "claude-config:work"
|
||||
)
|
||||
|
||||
#expect(value(after: "--claude-config-source", in: args) == "claude-config:work")
|
||||
}
|
||||
|
||||
@Test("status argv combined omits Claude config source")
|
||||
func statusSubcommandCombinedOmitsClaudeConfigSource() {
|
||||
let args = DataClient.statusSubcommand(
|
||||
period: .today,
|
||||
provider: .all,
|
||||
includeOptimize: false,
|
||||
scope: .combined,
|
||||
claudeConfigSourceId: "claude-config:work"
|
||||
)
|
||||
|
||||
#expect(value(after: "--scope", in: args) == "combined")
|
||||
#expect(!args.contains("--claude-config-source"))
|
||||
}
|
||||
|
||||
@Test("status argv supports LingTai TUI provider")
|
||||
func statusSubcommandSupportsLingTaiTUI() {
|
||||
let args = DataClient.statusSubcommand(
|
||||
period: .month,
|
||||
provider: .lingtaiTui,
|
||||
includeOptimize: false,
|
||||
scope: .local
|
||||
)
|
||||
|
||||
#expect(value(after: "--provider", in: args) == "lingtai-tui")
|
||||
#expect(value(after: "--period", in: args) == "month")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// NOTE: this does NOT reproduce the production permanent deadlock (16/16
|
||||
/// cooperative threads parked in waitUntilExit). In a short-lived unit-test
|
||||
/// process libdispatch spins up replacement threads for blocked workers, so
|
||||
/// even the old blocking-on-the-pool code completes here. The real deadlock
|
||||
/// 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.
|
||||
@Test("concurrent timed-out processes all complete")
|
||||
func concurrentTimedOutProcessesAllComplete() {
|
||||
let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4
|
||||
let done = DispatchSemaphore(value: 0)
|
||||
|
||||
Task {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for _ in 0..<count {
|
||||
group.addTask {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/sleep")
|
||||
process.arguments = ["30"]
|
||||
_ = try? await DataClient.runProcess(process, timeoutSeconds: 1, label: "sleep 30")
|
||||
}
|
||||
}
|
||||
}
|
||||
done.signal()
|
||||
}
|
||||
|
||||
// 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)
|
||||
#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".
|
||||
@Test("decode failure surfaces output")
|
||||
func decodeFailureSurfacesOutput() {
|
||||
struct Boom: Error {}
|
||||
let failure = CLIDecodeFailure(
|
||||
underlying: Boom(),
|
||||
stdoutByteCount: 13,
|
||||
stdoutSnippet: "(node) banner",
|
||||
stderr: "warn: x"
|
||||
)
|
||||
let text = String(describing: failure)
|
||||
#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).
|
||||
@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)
|
||||
#expect(text.contains("0 bytes"))
|
||||
#expect(text.contains("<empty>"))
|
||||
}
|
||||
|
||||
/// A normally-exiting process returns its real output and exit code through
|
||||
/// the off-pool wait path.
|
||||
@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")
|
||||
#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).
|
||||
@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..<count {
|
||||
group.addTask {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/echo")
|
||||
process.arguments = ["ok"]
|
||||
return try? await DataClient.runProcess(process, timeoutSeconds: 5, label: "echo ok").exitCode
|
||||
}
|
||||
}
|
||||
var out: [Int32?] = []
|
||||
for await code in group { out.append(code) }
|
||||
return out
|
||||
}
|
||||
#expect(codes.count == count)
|
||||
#expect(codes.allSatisfy { $0 == 0 },
|
||||
"every concurrent process should exit 0 via the terminationHandler wait path")
|
||||
}
|
||||
|
||||
/// The async semaphore never lets more than its count run concurrently.
|
||||
@Test("async semaphore caps concurrency")
|
||||
func asyncSemaphoreCapsConcurrency() async {
|
||||
let sem = AsyncSemaphore(2)
|
||||
let peak = PeakCounter()
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for _ in 0..<12 {
|
||||
group.addTask {
|
||||
await sem.acquire()
|
||||
await peak.enter()
|
||||
try? await Task.sleep(nanoseconds: 8_000_000)
|
||||
await peak.leave()
|
||||
await sem.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
let observed = await peak.peak
|
||||
#expect(observed <= 2, "semaphore should cap concurrency at 2, saw \(observed)")
|
||||
#expect(observed > 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
|
||||
func enter() { current += 1; peak = max(peak, current) }
|
||||
func leave() { current -= 1 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Menubar period settings")
|
||||
struct MenubarPeriodSettingsTests {
|
||||
@Test("settings picker exposes requested periods")
|
||||
func settingsPickerExposesRequestedPeriods() {
|
||||
#expect(Period.menubarMetricCases == [.today, .sevenDays, .month, .all])
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
||||
@Test("periods persist canonical defaults values")
|
||||
func periodsPersistCanonicalDefaultsValues() {
|
||||
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
Period.sevenDays.persistAsMenubarDefault(defaults: defaults)
|
||||
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "week")
|
||||
#expect(Period.savedMenubarPeriod(defaults: defaults) == .sevenDays)
|
||||
|
||||
Period.all.persistAsMenubarDefault(defaults: defaults)
|
||||
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "sixMonths")
|
||||
#expect(Period.savedMenubarPeriod(defaults: defaults) == .all)
|
||||
|
||||
Period.thirtyDays.persistAsMenubarDefault(defaults: defaults)
|
||||
#expect(defaults.string(forKey: "CodeBurnMenubarPeriod") == "today")
|
||||
#expect(Period.savedMenubarPeriod(defaults: defaults) == .today)
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("MenubarStatusCache")
|
||||
struct MenubarStatusCacheTests {
|
||||
private func tempDir() -> String {
|
||||
let dir = NSTemporaryDirectory() + "menubar-status-test-" + UUID().uuidString
|
||||
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
|
||||
private func validPayloadJSON(cost: Double) -> Data {
|
||||
let p = MenubarPayload(
|
||||
generated: "2026-05-29T00:00:00Z",
|
||||
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: nil
|
||||
)
|
||||
return try! JSONEncoder().encode(p)
|
||||
}
|
||||
|
||||
@Test("reads a fresh, valid status file")
|
||||
func readsValidFile() throws {
|
||||
let dir = tempDir()
|
||||
let path = dir + "/menubar-status.json"
|
||||
try validPayloadJSON(cost: 42.5).write(to: URL(fileURLWithPath: path))
|
||||
let cache = MenubarStatusCache(statusPath: path)
|
||||
|
||||
let result = cache.readBadgePayload(maxAgeSeconds: 3600)
|
||||
|
||||
#expect(result?.payload.current.cost == 42.5)
|
||||
#expect((result?.ageSeconds ?? .infinity) < 60)
|
||||
}
|
||||
|
||||
@Test("returns nil for a missing file")
|
||||
func missingFileReturnsNil() {
|
||||
let dir = tempDir()
|
||||
let cache = MenubarStatusCache(statusPath: dir + "/nope.json")
|
||||
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
|
||||
}
|
||||
|
||||
@Test("returns nil for a corrupt file")
|
||||
func corruptFileReturnsNil() throws {
|
||||
let dir = tempDir()
|
||||
let path = dir + "/menubar-status.json"
|
||||
try Data("{ not json".utf8).write(to: URL(fileURLWithPath: path))
|
||||
let cache = MenubarStatusCache(statusPath: path)
|
||||
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
|
||||
}
|
||||
|
||||
@Test("returns nil for an over-age file")
|
||||
func overAgeFileReturnsNil() throws {
|
||||
let dir = tempDir()
|
||||
let path = dir + "/menubar-status.json"
|
||||
let url = URL(fileURLWithPath: path)
|
||||
try validPayloadJSON(cost: 7).write(to: url)
|
||||
try FileManager.default.setAttributes(
|
||||
[.modificationDate: Date().addingTimeInterval(-7200)], ofItemAtPath: path
|
||||
)
|
||||
let cache = MenubarStatusCache(statusPath: path)
|
||||
#expect(cache.readBadgePayload(maxAgeSeconds: 3600) == nil)
|
||||
}
|
||||
|
||||
@Test("writeStatus round-trips through readBadgePayload")
|
||||
func writeStatusRoundTrips() throws {
|
||||
let dir = tempDir()
|
||||
let path = dir + "/menubar-status.json"
|
||||
let cache = MenubarStatusCache(statusPath: path)
|
||||
|
||||
let payload = try JSONDecoder().decode(MenubarPayload.self, from: validPayloadJSON(cost: 13.5))
|
||||
try cache.writeStatus(payload)
|
||||
|
||||
let result = cache.readBadgePayload(maxAgeSeconds: 3600)
|
||||
#expect(result?.payload.current.cost == 13.5)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import XCTest
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
final class RefreshCadenceTests: XCTestCase {
|
||||
func testAutoPopoverOpenAlwaysUsesActiveCadence() {
|
||||
XCTAssertEqual(
|
||||
RefreshCadence.interval(mode: .auto, popoverOpen: true, onBattery: true, lowPowerMode: true),
|
||||
RefreshCadence.activeSeconds
|
||||
)
|
||||
}
|
||||
|
||||
func testAutoIdleOnACStaysActive() {
|
||||
XCTAssertEqual(
|
||||
RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: false),
|
||||
RefreshCadence.activeSeconds
|
||||
)
|
||||
}
|
||||
|
||||
func testAutoIdleOnBatteryBacksOff() {
|
||||
XCTAssertEqual(
|
||||
RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: true, lowPowerMode: false),
|
||||
RefreshCadence.batteryIdleSeconds
|
||||
)
|
||||
}
|
||||
|
||||
func testAutoLowPowerModeBacksOffFurthest() {
|
||||
XCTAssertEqual(
|
||||
RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: true, lowPowerMode: true),
|
||||
RefreshCadence.lowPowerIdleSeconds
|
||||
)
|
||||
XCTAssertEqual(
|
||||
RefreshCadence.interval(mode: .auto, popoverOpen: false, onBattery: false, lowPowerMode: true),
|
||||
RefreshCadence.lowPowerIdleSeconds
|
||||
)
|
||||
}
|
||||
|
||||
func testManualNeverAutoSpawns() {
|
||||
XCTAssertNil(RefreshCadence.interval(mode: .manual, popoverOpen: false, onBattery: false, lowPowerMode: false))
|
||||
XCTAssertNil(RefreshCadence.interval(mode: .manual, popoverOpen: true, onBattery: false, lowPowerMode: false))
|
||||
}
|
||||
|
||||
func testFixedCadenceIgnoresPowerState() {
|
||||
XCTAssertEqual(
|
||||
RefreshCadence.interval(mode: .fiveMinutes, popoverOpen: false, onBattery: true, lowPowerMode: true),
|
||||
300
|
||||
)
|
||||
XCTAssertEqual(
|
||||
RefreshCadence.interval(mode: .fifteenMinutes, popoverOpen: false, onBattery: false, lowPowerMode: false),
|
||||
900
|
||||
)
|
||||
}
|
||||
|
||||
func testFixedCadenceGoesActiveWhilePopoverOpen() {
|
||||
XCTAssertEqual(
|
||||
RefreshCadence.interval(mode: .fiveMinutes, popoverOpen: true, onBattery: true, lowPowerMode: false),
|
||||
RefreshCadence.activeSeconds
|
||||
)
|
||||
}
|
||||
|
||||
func testBackoffOrdering() {
|
||||
XCTAssertLessThan(RefreshCadence.activeSeconds, RefreshCadence.batteryIdleSeconds)
|
||||
XCTAssertLessThan(RefreshCadence.batteryIdleSeconds, RefreshCadence.lowPowerIdleSeconds)
|
||||
}
|
||||
|
||||
func testCadenceDefaultsToAutoWhenUnset() {
|
||||
let key = UsageRefreshCadence.defaultsKey
|
||||
let saved = UserDefaults.standard.object(forKey: key)
|
||||
defer {
|
||||
if let saved { UserDefaults.standard.set(saved, forKey: key) }
|
||||
else { UserDefaults.standard.removeObject(forKey: key) }
|
||||
}
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
XCTAssertEqual(UsageRefreshCadence.current, .auto)
|
||||
|
||||
UsageRefreshCadence.current = .manual
|
||||
XCTAssertEqual(UsageRefreshCadence.current, .manual)
|
||||
UsageRefreshCadence.current = .fiveMinutes
|
||||
XCTAssertEqual(UsageRefreshCadence.current, .fiveMinutes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("UpdateChecker")
|
||||
struct UpdateCheckerTests {
|
||||
@Test("selects newest mac release with zip and checksum")
|
||||
func selectsNewestMacReleaseWithChecksum() {
|
||||
let releases = [
|
||||
GitHubRelease(
|
||||
tag_name: "v0.9.9",
|
||||
assets: [GitHubAsset(name: "codeburn-0.9.9.tgz", browser_download_url: "https://example.test/cli")]
|
||||
),
|
||||
GitHubRelease(
|
||||
tag_name: "mac-v0.9.8",
|
||||
assets: [
|
||||
GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app"),
|
||||
GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip.sha256", browser_download_url: "https://example.test/app.sha256"),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
let resolved = UpdateChecker.resolveLatestMenubarRelease(in: releases)
|
||||
|
||||
#expect(resolved?.release.tag_name == "mac-v0.9.8")
|
||||
#expect(resolved?.asset.name == "CodeBurnMenubar-v0.9.8.zip")
|
||||
}
|
||||
|
||||
@Test("ignores mac release missing checksum")
|
||||
func ignoresMacReleaseMissingChecksum() {
|
||||
let releases = [
|
||||
GitHubRelease(
|
||||
tag_name: "mac-v0.9.8",
|
||||
assets: [GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app")]
|
||||
),
|
||||
]
|
||||
|
||||
#expect(UpdateChecker.resolveLatestMenubarRelease(in: releases) == nil)
|
||||
}
|
||||
|
||||
@Test("flags CLI older than the menubar-update fix as too old")
|
||||
func flagsCliBelowMinimumAsTooOld() {
|
||||
#expect(UpdateChecker.isCliTooOld(installed: "0.9.8"))
|
||||
#expect(UpdateChecker.isCliTooOld(installed: "v0.9.8"))
|
||||
#expect(UpdateChecker.isCliTooOld(installed: "0.8.12"))
|
||||
}
|
||||
|
||||
@Test("accepts CLI at or above the menubar-update fix version")
|
||||
func acceptsCliAtOrAboveMinimum() {
|
||||
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.9"))
|
||||
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.10"))
|
||||
#expect(!UpdateChecker.isCliTooOld(installed: "0.9.14"))
|
||||
#expect(!UpdateChecker.isCliTooOld(installed: "1.0.0"))
|
||||
}
|
||||
|
||||
@Test("does not flag when the CLI version is unknown")
|
||||
func ignoresUnknownCliVersion() {
|
||||
#expect(!UpdateChecker.isCliTooOld(installed: nil))
|
||||
#expect(!UpdateChecker.isCliTooOld(installed: ""))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user