Skip to content

Commit 42639a1

Browse files
authored
chore: bump CI actions to v5, sync spec, add tests for extracted code (#112)
- Bump actions/checkout and actions/cache from v4 to v5 in both CI and release workflows (fixes Node.js 20 deprecation warnings) - Add new files to spec/ARCHITECTURE.md project tree: MenuBarIconGeometry, TokenHealthSessionInfo, ActivityChartData, ActivityChartTrend - Add 29 tests for SessionInfoFormatter (20) and ActivityTrendComputation (9) covering label parts, ID prefix, bottom parts, stale idle detection, detail tooltips, time formatting, vs-yesterday/week/month comparisons, month projection, and copy text formatting - Update README test coverage: 585→613 tests across 39→41 files Co-authored-by: KyleNesium <22541778+KyleNesium@users.noreply.github.com>
1 parent 1390499 commit 42639a1

File tree

6 files changed

+326
-6
lines changed

6 files changed

+326
-6
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,10 @@ jobs:
2929
runs-on: macos-15
3030
timeout-minutes: 10
3131
steps:
32-
- uses: actions/checkout@v4
32+
- uses: actions/checkout@v5
3333

3434
- name: Cache SPM
35-
uses: actions/cache@v4
35+
uses: actions/cache@v5
3636
with:
3737
path: .build
3838
key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved', 'Package.swift') }}

.github/workflows/release.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ jobs:
1616
HAS_SPARKLE_KEY: ${{ secrets.SPARKLE_EDDSA_KEY != '' }}
1717
HAS_HOMEBREW_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }}
1818
steps:
19-
- uses: actions/checkout@v4
19+
- uses: actions/checkout@v5
2020

2121
- name: Cache SPM
22-
uses: actions/cache@v4
22+
uses: actions/cache@v5
2323
with:
2424
path: .build
2525
key: spm-${{ runner.os }}-${{ hashFiles('Package.resolved', 'Package.swift') }}

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,13 +485,13 @@ If it saves you time or helps you get more out of your Claude subscription, cons
485485

486486
## 🧪 Test Coverage
487487

488-
**585 tests** across 39 test files.
488+
**613 tests** across 41 test files.
489489

490490
| Area | Tests | What's covered |
491491
|------|-------|----------------|
492492
| Models | 180 | Token summaries, rate limit parsing (predictive estimates, fresh window guard, unknown claim defaults, countdown formatter, throttled header parsing, per-window throttle detection, utilization clamping, past reset guard, threshold boundary), health status, metric modes, API profiles (org ID validation: empty, too long, special chars, hyphens/underscores), session entries (service_tier decode), account records, stats cache, usage snapshots (trends, busiest day, auto-resolved mode with priority tiers and edge cases, urgency score piecewise interpolation with midpoint verification and clamping, context health fallback chain, single-pass activity stats), model pricing (cache correctness for unknown models), health config |
493493
| Services | 231 | Token ledger (high-water-mark merge, historical model restoration, per-account isolation, persistence, sort order, all token types, file size guard), version checker (semver comparison, tag stripping, cache behavior, force check, stale cache discard, persistence keys), Sparkle update service (automatic checks disabled, automatic downloads disabled, check interval zero, feed URL, singleton identity, canCheckForUpdates), notification manager (alert thresholds, alert key migration), token health monitor (band classification, warnings, anomalies, velocity, rapid consumption, custom config, idle session inclusion), status checker (severity ordering, incident escalation, known components catalog, status string parsing), status indicator (dot colors, label text), session log reader (entry decoding, makeUsageEntry, symlink boundary check), account store (multi-account CRUD, persistence, merge metadata preservation), stats cache reader (decode, caching, invalidation, full payload, file size guard, symlink boundary check, prefix traversal attack), usage aggregator (empty state, stats-only, JSONL-only, rate limit pass-through, model filtering, deduplication, stats+JSONL merge, all-time mode, redundant aggregation skip, hourly merge, peak hour update, totalMessages dedup, old model visibility, all-dates daily merge, todayHourCounts separation), rate limit fetcher (cache expiry, stale marking, multi-account isolation, Retry-After parsing), OAuth manager (AuthError messages, transient error classification) |
494-
| Views | 14 | Activity chart data transformations (daily 7-day generation, gap filling, chronological ordering, hourly 12-hour window, midnight wraparound, month totals aggregation, invalid date handling, monthly 12-month generation, current-month projection, early-month projection skip, past-month no projection) |
494+
| Views | 43 | Activity chart data transformations (daily 7-day generation, gap filling, chronological ordering, hourly 12-hour window, midnight wraparound, month totals aggregation, invalid date handling, monthly 12-month generation, current-month projection, early-month projection skip, past-month no projection), activity trend computation (vs-yesterday positive/negative/same/nil, month change projection/nil/early-month, copy text formatting), session info formatter (label parts with project/branch/HEAD/empty, ID prefix truncation/nil/short, bottom parts with duration/velocity/fallback, stale idle detection by band/recency, detail tooltip content, time formatting just-now/minutes/today) |
495495
| ViewModels | 26 | UsageViewModel static helpers (refresh interval clamping, error message logic, adaptive polling data-change detection, throttle event recording with dedup + exhaustion detection, throttle count filtering + string timestamp parsing) |
496496
| Utilities | 136 | Token formatter (K/M suffixes, boundaries), model name mapper (display names, versions, date stripping, result cache), Claude paths (suffixes, URLs), theme colors (standard + colorblind palettes, NSColor, semantic colors, danger), UserDefaults keys (prefix, uniqueness), date formatters (format strings, round-trips, locale pinning, date range formatting), adaptive polling state (threshold behavior, progressive doubling, caps, reset), secure networking (ephemeral session config, singleton, size limit, cookie policy, resource timeout), duration formatter (compact format, boundaries, 24h edge case, days/hours/minutes/seconds), menu bar icon (breathing animation, recovery sparkle effect, quantized caching, broken star fragments, pulse steps, cache identity, sparkle/broken/normal key isolation, context health colors), throttle tracker (transition detection, recovery-then-throttle, exhaustion detection, no double-count, timestamp parsing for doubles/strings/ints/mixed, append-and-prune with 30-day cutoff, count filtering) |
497497

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import Testing
2+
import Foundation
3+
@testable import AIBatteryCore
4+
5+
@Suite("ActivityTrendComputation")
6+
@MainActor
7+
struct ActivityTrendTests {
8+
9+
// MARK: - changeVsYesterday
10+
11+
@Test func changeVsYesterday_positive() {
12+
let cal = Calendar.current
13+
let now = Date()
14+
let yesterdayStr = DateFormatters.dateKey.string(from: cal.date(byAdding: .day, value: -1, to: now)!)
15+
let snapshot = makeSnapshot(todayMessages: 15, dailyActivity: [
16+
DailyActivity(date: yesterdayStr, messageCount: 10, sessionCount: 1, toolCallCount: 0)
17+
])
18+
let change = ActivityTrendComputation.changeVsYesterday(snapshot, cal: cal, now: now)
19+
#expect(change?.symbol == "")
20+
#expect(change?.label == "+5 vs yesterday")
21+
}
22+
23+
@Test func changeVsYesterday_negative() {
24+
let cal = Calendar.current
25+
let now = Date()
26+
let yesterdayStr = DateFormatters.dateKey.string(from: cal.date(byAdding: .day, value: -1, to: now)!)
27+
let snapshot = makeSnapshot(todayMessages: 5, dailyActivity: [
28+
DailyActivity(date: yesterdayStr, messageCount: 10, sessionCount: 1, toolCallCount: 0)
29+
])
30+
let change = ActivityTrendComputation.changeVsYesterday(snapshot, cal: cal, now: now)
31+
#expect(change?.symbol == "")
32+
#expect(change?.label == "-5 vs yesterday")
33+
}
34+
35+
@Test func changeVsYesterday_same() {
36+
let cal = Calendar.current
37+
let now = Date()
38+
let yesterdayStr = DateFormatters.dateKey.string(from: cal.date(byAdding: .day, value: -1, to: now)!)
39+
let snapshot = makeSnapshot(todayMessages: 10, dailyActivity: [
40+
DailyActivity(date: yesterdayStr, messageCount: 10, sessionCount: 1, toolCallCount: 0)
41+
])
42+
let change = ActivityTrendComputation.changeVsYesterday(snapshot, cal: cal, now: now)
43+
#expect(change?.symbol == "")
44+
}
45+
46+
@Test func changeVsYesterday_nilWhenNoYesterdayData() {
47+
let snapshot = makeSnapshot(todayMessages: 10, dailyActivity: [])
48+
let change = ActivityTrendComputation.changeVsYesterday(snapshot)
49+
#expect(change == nil)
50+
}
51+
52+
// MARK: - monthChangeInfo
53+
54+
@Test func monthChange_positiveProjection() {
55+
let cal = Calendar.current
56+
// March 15, 2026 — dayOfMonth=15, daysInMonth=31
57+
let now = cal.date(from: DateComponents(year: 2026, month: 3, day: 15))!
58+
// thisMonth=100, projected=100*31/15=206, lastMonth=100 → +106% → ↑
59+
let change = ActivityTrendComputation.monthChangeInfo(thisMonth: 100, lastMonth: 100, cal: cal, now: now)
60+
#expect(change?.symbol == "")
61+
}
62+
63+
@Test func monthChange_nilWhenLastMonthZero() {
64+
let change = ActivityTrendComputation.monthChangeInfo(thisMonth: 50, lastMonth: 0)
65+
#expect(change == nil)
66+
}
67+
68+
@Test func monthChange_nilWhenTooEarlyInMonth() {
69+
let cal = Calendar.current
70+
let now = cal.date(from: DateComponents(year: 2026, month: 3, day: 2))!
71+
let change = ActivityTrendComputation.monthChangeInfo(thisMonth: 50, lastMonth: 100, cal: cal, now: now)
72+
#expect(change == nil) // dayOfMonth < 4
73+
}
74+
75+
// MARK: - copyText
76+
77+
@Test func copyText_includesAllParts() {
78+
let data = ActivityTrendData(
79+
change: ActivityChangeInfo(symbol: "", label: "+5 vs yesterday", color: .orange),
80+
stat: "15 msgs today",
81+
throttleCount: 2,
82+
peak: "Peak: 14:00",
83+
throttleDays: 1
84+
)
85+
let text = ActivityTrendComputation.copyText(data)
86+
#expect(text.contains("↑ +5 vs yesterday"))
87+
#expect(text.contains("15 msgs today"))
88+
#expect(text.contains("Throttled: 2×"))
89+
#expect(text.contains("Peak: 14:00"))
90+
}
91+
92+
@Test func copyText_zeroThrottles() {
93+
let data = ActivityTrendData(
94+
change: nil,
95+
stat: nil,
96+
throttleCount: 0,
97+
peak: nil,
98+
throttleDays: 1
99+
)
100+
let text = ActivityTrendComputation.copyText(data)
101+
#expect(text == "Throttled: 0")
102+
}
103+
104+
// MARK: - Helpers
105+
106+
private func makeSnapshot(
107+
todayMessages: Int = 0,
108+
dailyActivity: [DailyActivity] = []
109+
) -> UsageSnapshot {
110+
let stats = UsageSnapshot.computeActivityStats(dailyActivity)
111+
return UsageSnapshot(
112+
lastUpdated: Date(),
113+
rateLimits: nil,
114+
firstSessionDate: nil,
115+
totalSessions: 0,
116+
totalMessages: 0,
117+
longestSessionDuration: nil,
118+
longestSessionMessages: 0,
119+
peakHour: nil,
120+
peakHourCount: 0,
121+
todayMessages: todayMessages,
122+
todaySessions: 0,
123+
todayToolCalls: 0,
124+
modelTokens: [],
125+
totalTokens: 0,
126+
dailyActivity: dailyActivity,
127+
dailyAverage: stats.average,
128+
trendDirection: stats.trend,
129+
busiestDayOfWeek: stats.busiestDay,
130+
hourCounts: [:],
131+
todayHourCounts: [:],
132+
tokenHealth: nil,
133+
topSessionHealths: []
134+
)
135+
}
136+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import Testing
2+
import Foundation
3+
@testable import AIBatteryCore
4+
5+
@Suite("SessionInfoFormatter")
6+
struct SessionInfoFormatterTests {
7+
8+
// MARK: - Label parts
9+
10+
@Test func labelParts_projectAndBranch() {
11+
let health = makeHealth(projectName: "MyApp", gitBranch: "main")
12+
let parts = SessionInfoFormatter.labelParts(for: health)
13+
#expect(parts == ["MyApp", "main"])
14+
}
15+
16+
@Test func labelParts_projectOnly() {
17+
let health = makeHealth(projectName: "MyApp", gitBranch: nil)
18+
let parts = SessionInfoFormatter.labelParts(for: health)
19+
#expect(parts == ["MyApp"])
20+
}
21+
22+
@Test func labelParts_skipsHEADBranch() {
23+
let health = makeHealth(projectName: "MyApp", gitBranch: "HEAD")
24+
let parts = SessionInfoFormatter.labelParts(for: health)
25+
#expect(parts == ["MyApp"])
26+
}
27+
28+
@Test func labelParts_skipsEmptyBranch() {
29+
let health = makeHealth(projectName: "MyApp", gitBranch: "")
30+
let parts = SessionInfoFormatter.labelParts(for: health)
31+
#expect(parts == ["MyApp"])
32+
}
33+
34+
@Test func labelParts_empty() {
35+
let health = makeHealth(projectName: nil, gitBranch: nil)
36+
let parts = SessionInfoFormatter.labelParts(for: health)
37+
#expect(parts.isEmpty)
38+
}
39+
40+
// MARK: - ID prefix
41+
42+
@Test func idPrefix_truncatesTo8Chars() {
43+
let health = makeHealth(id: "abcdef1234567890")
44+
#expect(SessionInfoFormatter.idPrefix(for: health) == "abcdef12")
45+
}
46+
47+
@Test func idPrefix_nilForEmptyId() {
48+
let health = makeHealth(id: "")
49+
#expect(SessionInfoFormatter.idPrefix(for: health) == nil)
50+
}
51+
52+
@Test func idPrefix_shortIdUnchanged() {
53+
let health = makeHealth(id: "abc")
54+
#expect(SessionInfoFormatter.idPrefix(for: health) == "abc")
55+
}
56+
57+
// MARK: - Bottom parts
58+
59+
@Test func bottomParts_includesDurationAndVelocity() {
60+
let health = makeHealth(
61+
sessionDuration: 3600,
62+
lastActivity: Date(),
63+
tokensPerMinute: 1500
64+
)
65+
let parts = SessionInfoFormatter.bottomParts(for: health)
66+
#expect(parts.count == 3)
67+
#expect(parts[0] == "1h 0m") // DurationFormatter.compact(3600)
68+
#expect(parts[2] == "1.5K/min")
69+
}
70+
71+
@Test func bottomParts_emptyWhenNoMetadata() {
72+
let health = makeHealth()
73+
let parts = SessionInfoFormatter.bottomParts(for: health)
74+
#expect(parts.isEmpty)
75+
}
76+
77+
@Test func bottomParts_fallsBackToSessionStart() {
78+
let health = makeHealth(
79+
sessionStart: Date(),
80+
lastActivity: nil
81+
)
82+
let parts = SessionInfoFormatter.bottomParts(for: health)
83+
#expect(parts.count == 1)
84+
#expect(parts[0].contains("just now") || parts[0].contains("Today"))
85+
}
86+
87+
// MARK: - Stale idle minutes
88+
89+
@Test func staleIdleMinutes_nilForGreenBand() {
90+
let health = makeHealth(band: .green, lastActivity: Date().addingTimeInterval(-3600))
91+
#expect(SessionInfoFormatter.staleIdleMinutes(for: health) == nil)
92+
}
93+
94+
@Test func staleIdleMinutes_nilForRecentActivity() {
95+
let health = makeHealth(band: .orange, lastActivity: Date().addingTimeInterval(-60))
96+
#expect(SessionInfoFormatter.staleIdleMinutes(for: health) == nil)
97+
}
98+
99+
@Test func staleIdleMinutes_returnsMinutesWhenStale() {
100+
let health = makeHealth(band: .orange, lastActivity: Date().addingTimeInterval(-3600))
101+
let minutes = SessionInfoFormatter.staleIdleMinutes(for: health)
102+
#expect(minutes == 60)
103+
}
104+
105+
// MARK: - Detail tooltip
106+
107+
@Test func detailTooltip_includesSessionId() {
108+
let health = makeHealth(id: "test-session-123")
109+
let tooltip = SessionInfoFormatter.detailTooltip(for: health)
110+
#expect(tooltip.contains("Session: test-session-123"))
111+
}
112+
113+
@Test func detailTooltip_includesModel() {
114+
let health = makeHealth(model: "claude-opus-4-6-20250929")
115+
let tooltip = SessionInfoFormatter.detailTooltip(for: health)
116+
#expect(tooltip.contains("Model: Opus 4.6"))
117+
}
118+
119+
@Test func detailTooltip_includesContext() {
120+
let health = makeHealth(totalUsed: 50000, usableWindow: 160000)
121+
let tooltip = SessionInfoFormatter.detailTooltip(for: health)
122+
#expect(tooltip.contains("Context:"))
123+
}
124+
125+
// MARK: - Time formatting
126+
127+
@Test func formatSessionTime_justNow() {
128+
let result = SessionInfoFormatter.formatSessionTime(Date())
129+
#expect(result == "just now")
130+
}
131+
132+
@Test func formatSessionTime_minutesAgo() {
133+
let result = SessionInfoFormatter.formatSessionTime(Date().addingTimeInterval(-300))
134+
#expect(result == "5m ago")
135+
}
136+
137+
@Test func formatSessionTime_todayShowsTime() {
138+
let result = SessionInfoFormatter.formatSessionTime(Date().addingTimeInterval(-7200))
139+
#expect(result.hasPrefix("Today"))
140+
}
141+
142+
// MARK: - Helpers
143+
144+
private func makeHealth(
145+
id: String = "session-1",
146+
band: HealthBand = .green,
147+
model: String = "",
148+
projectName: String? = nil,
149+
gitBranch: String? = nil,
150+
sessionStart: Date? = nil,
151+
sessionDuration: TimeInterval? = nil,
152+
lastActivity: Date? = nil,
153+
tokensPerMinute: Double? = nil,
154+
totalUsed: Int = 0,
155+
usableWindow: Int = 160000
156+
) -> TokenHealthStatus {
157+
TokenHealthStatus(
158+
id: id,
159+
band: band,
160+
usagePercentage: 0,
161+
totalUsed: totalUsed,
162+
contextWindow: 200000,
163+
usableWindow: usableWindow,
164+
remainingTokens: usableWindow - totalUsed,
165+
inputTokens: 0,
166+
outputTokens: 0,
167+
cacheReadTokens: 0,
168+
cacheWriteTokens: 0,
169+
model: model,
170+
turnCount: 0,
171+
warnings: [],
172+
tokensPerMinute: tokensPerMinute,
173+
projectName: projectName,
174+
gitBranch: gitBranch,
175+
sessionStart: sessionStart,
176+
sessionDuration: sessionDuration,
177+
lastActivity: lastActivity
178+
)
179+
}
180+
}

spec/ARCHITECTURE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ AIBattery/
9898
Views/
9999
StatusBarManager.swift — NSStatusItem + floating NSPanel, native AppKit button, controlBackgroundColor, Combine-driven updates
100100
MenuBarIcon.swift — 4-pointed star NSImage: breathing glow, broken star (throttled), recovery sparkle; quantized cache
101+
MenuBarIconGeometry.swift — Star path geometry helpers (starPath, brokenStarFragments, drawStroke) + NSBezierPath→CGPath
101102
UsagePopoverView.swift — Main popover: header, metric toggle, ordered sections, footer
102103
Settings/
103104
SettingsRow.swift — Inline settings container: account names + sub-sections
@@ -109,9 +110,12 @@ AIBattery/
109110
TutorialOverlay.swift — First-launch 3-step walkthrough overlay
110111
UsageBarsSection.swift — FiveHourBarSection + SevenDayBarSection rate limit bars
111112
TokenHealthSection.swift — Context health gauge + warnings + multi-session chevron toggle
113+
TokenHealthSessionInfo.swift — Session detail computation: label parts, tooltip, idle detection, time formatting
112114
TokenUsageSection.swift — Per-model token breakdown with token type tags + optional cost
113115
InsightsSection.swift — Collapsible insights: Today, All Time, Longest, Tools, Period (date range)
114116
ActivityChartView.swift — 12H/7D/12M activity chart (Swift Charts, rolling windows)
117+
ActivityChartData.swift — Chart data transformation layer (daily/hourly/monthly points)
118+
ActivityChartTrend.swift — Trend computation: vs-yesterday/week/month comparisons + copy text
115119
CollapsibleSectionHeader.swift — Shared collapsible header with rotating chevron, used by 4 sections
116120
FooterLink.swift — Footer link button with hover underline and external arrow
117121
RefreshButton.swift — Refresh button with brief spin animation

0 commit comments

Comments
 (0)