codeburn/mac/Tests/CodeBurnMenubarTests/CapacityEstimatorTests.swift
Resham Joshi 495a254338 feat(mac): native Swift menubar app + one-command install
Introduces mac/ with a native SwiftUI menubar app that replaces the
previous SwiftBar plugin entirely. Install via `npx codeburn menubar`,
which downloads the .app from GitHub Releases, strips Gatekeeper
quarantine, and drops it into ~/Applications.

Highlights

- mac/ SwiftUI app: agent tabs, Today/7/30/Month/All period switcher,
  Trend/Forecast/Pulse/Stats/Plan insights, activity + model
  breakdowns, optimize findings, CSV/JSON export, Star-on-GitHub
  banner, live 60s refresh, instant currency switching with offline FX
  cache.
- Security: CodeburnCLI argv-based spawn (no shell interpretation),
  SafeFile symlink guards + O_NOFOLLOW writes, FX rate clamping to
  [0.0001, 1_000_000], keychain filtered to account == "default",
  removed byte-window credential log, in-flight refresh guard, POSIX
  flock on config.json writes, TerminalLauncher validates argv before
  AppleScript interpolation.
- Performance: shared static NumberFormatter (thousands of allocations
  per popover redraw eliminated), concurrent pipe drain with 20 MB cap
  + 60s timeout in DataClient, Observation-tracked reactive UI, 5-min
  payload cache keyed on (period, provider).
- CLI: new `codeburn menubar` subcommand that downloads + installs +
  launches the .app (no clone, no build). New `status --format
  menubar-json` payload builder. `export` rewritten to produce a
  folder of one-table-per-file CSVs with a `.codeburn-export` marker
  so arbitrary -o paths cannot be silently deleted.
- Removed: src/menubar.ts (SwiftBar plugin generator),
  install-menubar / uninstall-menubar subcommands, `status --format
  menubar` directive output, tests/menubar.test.ts,
  tests/security/menubar-injection.test.ts.
- Release: .github/workflows/release-menubar.yml builds universal
  binary, assembles .app, ad-hoc signs, zips, uploads on mac-v* tag
  push. Runs on the free macos-latest runner.

Tests

- 230 TypeScript tests pass
- 10 Swift CapacityEstimator tests pass
- TypeScript typecheck clean
- Swift release build clean
2026-04-17 16:55:56 -07:00

158 lines
6.2 KiB
Swift

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)
}
}