on master help sign in
fix: URL-match stub responses for concurrent z.ai tests

The z.ai client fetches quota and subscription endpoints concurrently via async let, but StubURLProtocol was pure FIFO — whichever request arrived first got the first response, causing testFetchUsageSuccess to flap. Added URL-path-matched enqueueing so each endpoint gets its own response regardless of arrival order. Also: hide unconfigured services from menu bar + show z.ai weekly limit + remove credits from README (squashed from prior commits).

commit34ae19e77a9f082ecd522861b98b6c03e24d03be authoriko <mail@iko.soy> dateFri 17 Jul 2026, 08:22 (2 months ago) parente71c8bf
7 files changed+200−75
CLAUDE.md+6−3 README.md−6 UsageMenubar/UsageMenubarApp.swift+11−6 UsageMenubar/ViewModel.swift+26−10 UsageMenubarTests/CreditsCheckerTests.swift+19 UsageMenubarTests/StatusBarTextTests.swift+115−22 UsageMenubarTests/ZaiUsageClientTests.swift+23−28
CLAUDE.md+6−3
@@ -14,9 +14,12 @@ z.ai Coding Plan usage limits when an API key is entered in settings.
1414- `VersionFormatter.swift` — Version string formatting
1515
1616The status bar title shows `⚡{balance}` (Hyper), `🕐{percent}%` (Claude 5-hour),
17−`📅{percent}%` (Claude 7-day), and `🤖{percent}%` (z.ai 5-hour) — each omitted when
18−absent or at 0%. The three fetches are independent — `ViewModel.refresh()` runs them
19−with `async let`, and any can fail or be unconfigured without touching the others.
17+`📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and `📆{percent}%`
18+(z.ai weekly) — each omitted when absent or at 0%, and entirely absent when the
19+service is not configured (no API key or no credentials). An unconfigured service
20+produces no placeholder in the menu bar. The three fetches are independent —
21+`ViewModel.refresh()` runs them with `async let`, and any can fail or be
22+unconfigured without touching the others.
2023
2124## Claude credentials (strictly read-only)
2225`ClaudeUsageClient` reads the credentials **Claude Code owns**: its `Claude Code-credentials`
README.md−6
@@ -172,12 +172,6 @@ usage-menubar/
17217210. **Independent fetches**: `ViewModel.refresh()` runs the Hyper and Claude requests concurrently with `async let`. Neither can fail, stall, or be unconfigured in a way that affects the other.
17317311. **No sandbox**: Reading credentials that belong to another app means spawning `/usr/bin/security` and reading a file in the user's home directory, neither of which the App Sandbox permits. The app is distributed ad-hoc signed via Homebrew rather than through the App Store, where the sandbox would be mandatory.
174174
175−## Credits
176−
177−- **Author**: Ilya Koo ([@ilyakooo0](https://github.com/ilyakooo0))
178−- **API**: [Hyper by Charm](https://hyper.charm.land)
179−- **Built with**: Swift 5.9, SwiftUI, macOS 14+
180−
181175## License
182176
183177MIT — see [LICENSE](LICENSE).
UsageMenubar/UsageMenubarApp.swift+11−6
@@ -35,7 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
3535
3636 if let button = statusItem.button {
3737 button.image = nil
38− button.title = "⚡?"
38+ button.title = "…"
3939 button.target = self
4040 button.action = #selector(statusItemClicked(_:))
4141 button.sendAction(on: [.leftMouseUp, .rightMouseUp])
@@ -46,8 +46,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
4646 popover.behavior = .transient
4747 popover.contentViewController = NSHostingController(rootView: MenuView(viewModel: viewModel))
4848
49− // Observe published changes (balance + isLoading + Claude usage + z.ai usage) to
50− // update the menu bar title. CombineLatest fires when any of them changes.
49+ // Observe published changes (balance + isLoading + hyperConfigured +
50+ // Claude usage + z.ai usage) to update the menu bar title. CombineLatest
51+ // fires when any of them changes.
5152 //
5253 // The title is built from the values the publisher hands us, not by reading the
5354 // view model back: `@Published` emits in `willSet`, so the property still holds
@@ -56,13 +57,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
5657 // Delivery is hopped through `DispatchQueue.main` rather than `RunLoop.main`,
5758 // whose Combine scheduler only runs in the default run loop mode — the title
5859 // would freeze while a menu or a scroll was tracking.
59− statusTextCancellable = Publishers.CombineLatest4(
60+ statusTextCancellable = Publishers.CombineLatest5(
6061 viewModel.$balance,
6162 viewModel.$isLoading,
63+ viewModel.$hyperConfigured,
6264 viewModel.$claudeUsage,
6365 viewModel.$zaiUsage
6466 )
65− .map { balance, isLoading, claudeUsage, zaiUsage in
67+ .map { balance, isLoading, hyperConfigured, claudeUsage, zaiUsage in
6668 let fiveHour = claudeUsage?.fiveHour
6769 .map { Int($0.utilization.rounded()) }
6870 let sevenDay = claudeUsage?.sevenDay
@@ -70,12 +72,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
7072 ?? claudeUsage?.sevenDayOpus
7173 .map { Int($0.utilization.rounded()) }
7274 let zaiFiveHour = zaiUsage?.fiveHourPercent
75+ let zaiWeekly = zaiUsage?.weeklyPercent
7376 return ViewModel.statusBarText(
7477 balance: balance,
7578 isLoading: isLoading,
79+ hyperConfigured: hyperConfigured,
7680 claudeFiveHourPercent: fiveHour,
7781 claudeSevenDayPercent: sevenDay,
78− zaiFiveHourPercent: zaiFiveHour
82+ zaiFiveHourPercent: zaiFiveHour,
83+ zaiWeeklyPercent: zaiWeekly
7984 )
8085 }
8186 .removeDuplicates()
UsageMenubar/ViewModel.swift+26−10
@@ -93,6 +93,11 @@ final class ViewModel: ObservableObject {
9393 /// `activeZaiAPIKey`.
9494 @Published var zaiAPIKeyInput: String = ""
9595
96+ /// Whether Hyper is configured — i.e. there is an API key in the Keychain.
97+ /// Drives whether the `⚡` segment appears in the menu bar at all: an
98+ /// unconfigured service should not take up space in the title.
99+ @Published private(set) var hyperConfigured = false
100+
96101 /// The API key draft bound to the text field. Two-way bound to the `SecureField`,
97102 /// so it changes on every keystroke — never fetch with it, use `activeAPIKey`.
98103 @Published var apiKeyInput: String = ""
@@ -178,6 +183,7 @@ final class ViewModel: ObservableObject {
178183 let storedKey = KeychainHelper.load() ?? ""
179184 apiKeyInput = storedKey
180185 activeAPIKey = storedKey
186+ hyperConfigured = !storedKey.isEmpty
181187 let storedZaiKey = ZaiKeychainHelper.load() ?? ""
182188 zaiAPIKeyInput = storedZaiKey
183189 activeZaiAPIKey = storedZaiKey
@@ -209,12 +215,11 @@ final class ViewModel: ObservableObject {
209215 balanceFormatter.string(from: NSNumber(value: value)) ?? "\(value)"
210216 }
211217
212− /// The text to show in the menu bar: `⚡{balance}` followed by Claude usage
213− /// windows, each prefixed with an emoji so they are distinguishable at a glance:
214− /// 🕐 for the 5-hour window, 📅 for the 7-day window, 🤖 for the z.ai 5-hour
215− /// window. Windows that are absent or at 0% are omitted; with neither, the bolt
216− /// stands in alone: `⚡…` while the first fetch is in flight, `⚡?` once it has
217− /// come back empty-handed.
218+ /// The text to show in the menu bar: `⚡{balance}` (Hyper), `🕐{percent}%` (Claude
219+ /// 5-hour), `📅{percent}%` (Claude 7-day), `🤖{percent}%` (z.ai 5-hour), and
220+ /// `📆{percent}%` (z.ai weekly) — each omitted when absent or at 0%, and entirely
221+ /// absent when the service is not configured. An unconfigured service produces no
222+ /// placeholder in the menu bar.
218223 ///
219224 /// Takes its values as parameters rather than reading the properties: `@Published`
220225 /// publishes in `willSet`, so a Combine subscriber that called back into the view
@@ -222,12 +227,14 @@ final class ViewModel: ObservableObject {
222227 static func statusBarText(
223228 balance: Int?,
224229 isLoading: Bool,
230+ hyperConfigured: Bool,
225231 claudeFiveHourPercent: Int?,
226232 claudeSevenDayPercent: Int?,
227− zaiFiveHourPercent: Int?
233+ zaiFiveHourPercent: Int?,
234+ zaiWeeklyPercent: Int?
228235 ) -> String {
229236 var parts: [String] = []
230− if let balance = balance {
237+ if hyperConfigured, let balance = balance {
231238 parts.append("⚡\(formatBalance(balance))")
232239 }
233240 if let fiveHour = claudeFiveHourPercent, fiveHour > 0 {
@@ -239,8 +246,13 @@ final class ViewModel: ObservableObject {
239246 if let zaiFiveHour = zaiFiveHourPercent, zaiFiveHour > 0 {
240247 parts.append("🤖\(zaiFiveHour)%")
241248 }
249+ if let zaiWeekly = zaiWeeklyPercent, zaiWeekly > 0 {
250+ parts.append("📆\(zaiWeekly)%")
251+ }
242252 if parts.isEmpty {
243− return isLoading ? "⚡…" : "⚡?"
253+ // No service is configured or has anything to show. While loading,
254+ // show an ellipsis; otherwise the title is empty.
255+ return isLoading ? "…" : ""
244256 }
245257 return parts.joined(separator: " · ")
246258 }
@@ -250,9 +262,11 @@ final class ViewModel: ObservableObject {
250262 Self.statusBarText(
251263 balance: balance,
252264 isLoading: isLoading,
265+ hyperConfigured: hyperConfigured,
253266 claudeFiveHourPercent: claudeFiveHourPercent,
254267 claudeSevenDayPercent: claudeSevenDayPercent,
255− zaiFiveHourPercent: zaiFiveHourPercent
268+ zaiFiveHourPercent: zaiFiveHourPercent,
269+ zaiWeeklyPercent: zaiWeeklyPercent
256270 )
257271 }
258272
@@ -596,6 +610,7 @@ final class ViewModel: ObservableObject {
596610 }
597611 apiKeyInput = ""
598612 activeAPIKey = ""
613+ hyperConfigured = false
599614 // refresh() with an empty key clears balance, error, history, and
600615 // cancels any in-flight task from the previous key.
601616 refresh()
@@ -619,6 +634,7 @@ final class ViewModel: ObservableObject {
619634 // Show the trimmed key that was actually stored.
620635 apiKeyInput = key
621636 activeAPIKey = key
637+ hyperConfigured = true
622638 refresh()
623639 }
624640
UsageMenubarTests/CreditsCheckerTests.swift+19
@@ -519,10 +519,22 @@ final class StubURLProtocol: URLProtocol {
519519 Self.outcomes.append(contentsOf: outcomes)
520520 }
521521
522+ /// Queues an outcome for a specific URL path. URL-matched outcomes are consumed
523+ /// before the FIFO queue, so concurrent requests to different endpoints each get
524+ /// the right response regardless of arrival order.
525+ static func enqueue(_ outcome: Outcome, forURLPath path: String) {
526+ lock.lock()
527+ defer { lock.unlock() }
528+ urlMatchedOutcomes[path] = outcome
529+ }
530+
531+ private static var urlMatchedOutcomes: [String: Outcome] = [:]
532+
522533 static func reset() {
523534 lock.lock()
524535 defer { lock.unlock() }
525536 outcomes = []
537+ urlMatchedOutcomes = [:]
526538 lastOutcome = nil
527539 requests = []
528540 bodies = []
@@ -548,12 +560,19 @@ final class StubURLProtocol: URLProtocol {
548560 }
549561
550562 /// Records the request and pops the outcome that should answer it.
563+ /// URL-matched outcomes take priority over the FIFO queue, so concurrent
564+ /// requests to different endpoints each get the right response.
551565 private static func nextOutcome(for request: URLRequest) -> Outcome {
552566 lock.lock()
553567 defer { lock.unlock() }
554568 requests.append(request)
555569 bodies.append(drainBody(from: request))
556570
571+ if let path = request.url?.path,
572+ let matched = urlMatchedOutcomes[path] {
573+ return matched
574+ }
575+
557576 if !outcomes.isEmpty {
558577 lastOutcome = outcomes.removeFirst()
559578 }
UsageMenubarTests/StatusBarTextTests.swift+115−22
@@ -13,8 +13,9 @@ final class StatusBarTextTests: XCTestCase {
1313 func testBothServices() {
1414 let title = ViewModel.statusBarText(
1515 balance: 42, isLoading: false,
16+ hyperConfigured: true,
1617 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
17− zaiFiveHourPercent: nil
18+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
1819 )
1920 XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
2021 }
@@ -22,8 +23,9 @@ final class StatusBarTextTests: XCTestCase {
2223 func testHyperOnly() {
2324 let title = ViewModel.statusBarText(
2425 balance: 42, isLoading: false,
26+ hyperConfigured: true,
2527 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
26− zaiFiveHourPercent: nil
28+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
2729 )
2830 XCTAssertEqual(title, "⚡42")
2931 }
@@ -31,8 +33,9 @@ final class StatusBarTextTests: XCTestCase {
3133 func testClaudeOnly() {
3234 let title = ViewModel.statusBarText(
3335 balance: nil, isLoading: false,
36+ hyperConfigured: false,
3437 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
35− zaiFiveHourPercent: nil
38+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
3639 )
3740 XCTAssertEqual(title, "🕐62% · 📅8%")
3841 }
@@ -40,29 +43,32 @@ final class StatusBarTextTests: XCTestCase {
4043 func testLoadingWithNothingToShow() {
4144 let title = ViewModel.statusBarText(
4245 balance: nil, isLoading: true,
46+ hyperConfigured: false,
4347 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
44− zaiFiveHourPercent: nil
48+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
4549 )
46− XCTAssertEqual(title, "⚡…")
50+ XCTAssertEqual(title, "…")
4751 }
4852
4953 func testNothingToShow() {
5054 let title = ViewModel.statusBarText(
5155 balance: nil, isLoading: false,
56+ hyperConfigured: false,
5257 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
53− zaiFiveHourPercent: nil
58+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
5459 )
55− XCTAssertEqual(title, "⚡?")
60+ XCTAssertEqual(title, "")
5661 }
5762
5863 /// The placeholders only stand in for an *empty* title. Someone who runs Claude Code
59− /// without a Hyper key refreshes every few minutes, and a title that fell back to `⚡…`
64+ /// without a Hyper key refreshes every few minutes, and a title that fell back to `…`
6065 /// each time would blink their percentage away and back.
6166 func testLoadingKeepsAClaudePercentOnScreen() {
6267 let title = ViewModel.statusBarText(
6368 balance: nil, isLoading: true,
69+ hyperConfigured: false,
6470 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
65− zaiFiveHourPercent: nil
71+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
6672 )
6773 XCTAssertEqual(title, "🕐62%")
6874 }
@@ -70,8 +76,9 @@ final class StatusBarTextTests: XCTestCase {
7076 func testLoadingKeepsAStaleBalanceOnScreen() {
7177 let title = ViewModel.statusBarText(
7278 balance: 42, isLoading: true,
79+ hyperConfigured: true,
7380 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
74− zaiFiveHourPercent: nil
81+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
7582 )
7683 XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8%")
7784 }
@@ -79,8 +86,9 @@ final class StatusBarTextTests: XCTestCase {
7986 func testZeroPercentIsShownRatherThanOmitted() {
8087 let title = ViewModel.statusBarText(
8188 balance: 42, isLoading: false,
89+ hyperConfigured: true,
8290 claudeFiveHourPercent: 0, claudeSevenDayPercent: 8,
83− zaiFiveHourPercent: nil
91+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
8492 )
8593 XCTAssertEqual(title, "⚡42 · 📅8%")
8694 }
@@ -88,8 +96,9 @@ final class StatusBarTextTests: XCTestCase {
8896 func testBothClaudeWindowsZero() {
8997 let title = ViewModel.statusBarText(
9098 balance: 42, isLoading: false,
99+ hyperConfigured: true,
91100 claudeFiveHourPercent: 0, claudeSevenDayPercent: 0,
92− zaiFiveHourPercent: nil
101+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
93102 )
94103 XCTAssertEqual(title, "⚡42")
95104 }
@@ -97,8 +106,9 @@ final class StatusBarTextTests: XCTestCase {
97106 func testFiveHourOnly() {
98107 let title = ViewModel.statusBarText(
99108 balance: nil, isLoading: false,
109+ hyperConfigured: false,
100110 claudeFiveHourPercent: 62, claudeSevenDayPercent: nil,
101− zaiFiveHourPercent: nil
111+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
102112 )
103113 XCTAssertEqual(title, "🕐62%")
104114 }
@@ -106,19 +116,55 @@ final class StatusBarTextTests: XCTestCase {
106116 func testSevenDayOnly() {
107117 let title = ViewModel.statusBarText(
108118 balance: nil, isLoading: false,
119+ hyperConfigured: false,
109120 claudeFiveHourPercent: nil, claudeSevenDayPercent: 8,
110− zaiFiveHourPercent: nil
121+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
111122 )
112123 XCTAssertEqual(title, "📅8%")
113124 }
114125
126+ // MARK: - Hyper not configured
127+
128+ func testHyperNotConfiguredHidesBoltEvenWithBalance() {
129+ // balance is non-nil but Hyper is not configured — should not appear.
130+ // This can happen briefly during the transition when a key is deleted.
131+ let title = ViewModel.statusBarText(
132+ balance: 42, isLoading: false,
133+ hyperConfigured: false,
134+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
135+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
136+ )
137+ XCTAssertEqual(title, "")
138+ }
139+
140+ func testHyperNotConfiguredLoadingShowsJustDots() {
141+ let title = ViewModel.statusBarText(
142+ balance: nil, isLoading: true,
143+ hyperConfigured: false,
144+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
145+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
146+ )
147+ XCTAssertEqual(title, "…")
148+ }
149+
150+ func testHyperNotConfiguredButClaudeStillShows() {
151+ let title = ViewModel.statusBarText(
152+ balance: nil, isLoading: false,
153+ hyperConfigured: false,
154+ claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
155+ zaiFiveHourPercent: nil, zaiWeeklyPercent: nil
156+ )
157+ XCTAssertEqual(title, "🕐62% · 📅8%")
158+ }
159+
115160 // MARK: - z.ai in Title
116161
117162 func testZaiOnly() {
118163 let title = ViewModel.statusBarText(
119164 balance: nil, isLoading: false,
165+ hyperConfigured: false,
120166 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
121− zaiFiveHourPercent: 12
167+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
122168 )
123169 XCTAssertEqual(title, "🤖12%")
124170 }
@@ -126,8 +172,9 @@ final class StatusBarTextTests: XCTestCase {
126172 func testZaiWithHyper() {
127173 let title = ViewModel.statusBarText(
128174 balance: 42, isLoading: false,
175+ hyperConfigured: true,
129176 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
130− zaiFiveHourPercent: 12
177+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
131178 )
132179 XCTAssertEqual(title, "⚡42 · 🤖12%")
133180 }
@@ -135,8 +182,9 @@ final class StatusBarTextTests: XCTestCase {
135182 func testZaiWithClaude() {
136183 let title = ViewModel.statusBarText(
137184 balance: nil, isLoading: false,
185+ hyperConfigured: false,
138186 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
139− zaiFiveHourPercent: 12
187+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
140188 )
141189 XCTAssertEqual(title, "🕐62% · 📅8% · 🤖12%")
142190 }
@@ -144,17 +192,19 @@ final class StatusBarTextTests: XCTestCase {
144192 func testAllThreeServices() {
145193 let title = ViewModel.statusBarText(
146194 balance: 42, isLoading: false,
195+ hyperConfigured: true,
147196 claudeFiveHourPercent: 62, claudeSevenDayPercent: 8,
148− zaiFiveHourPercent: 12
197+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
149198 )
150− XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12%")
199+ XCTAssertEqual(title, "⚡42 · 🕐62% · 📅8% · 🤖12% · 📆3%")
151200 }
152201
153202 func testZaiZeroIsOmitted() {
154203 let title = ViewModel.statusBarText(
155204 balance: 42, isLoading: false,
205+ hyperConfigured: true,
156206 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
157− zaiFiveHourPercent: 0
207+ zaiFiveHourPercent: 0, zaiWeeklyPercent: 0
158208 )
159209 XCTAssertEqual(title, "⚡42")
160210 }
@@ -162,12 +212,55 @@ final class StatusBarTextTests: XCTestCase {
162212 func testLoadingKeepsZaiPercentOnScreen() {
163213 let title = ViewModel.statusBarText(
164214 balance: nil, isLoading: true,
215+ hyperConfigured: false,
216+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
217+ zaiFiveHourPercent: 12, zaiWeeklyPercent: nil
218+ )
219+ XCTAssertEqual(title, "🤖12%")
220+ }
221+
222+ // MARK: - z.ai Weekly
223+
224+ func testZaiWeeklyOnly() {
225+ let title = ViewModel.statusBarText(
226+ balance: nil, isLoading: false,
227+ hyperConfigured: false,
228+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
229+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
230+ )
231+ XCTAssertEqual(title, "📆3%")
232+ }
233+
234+ func testZaiWeeklyWithFiveHour() {
235+ let title = ViewModel.statusBarText(
236+ balance: nil, isLoading: false,
237+ hyperConfigured: false,
165238 claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
166− zaiFiveHourPercent: 12
239+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 3
240+ )
241+ XCTAssertEqual(title, "🤖12% · 📆3%")
242+ }
243+
244+ func testZaiWeeklyZeroIsOmitted() {
245+ let title = ViewModel.statusBarText(
246+ balance: nil, isLoading: false,
247+ hyperConfigured: false,
248+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
249+ zaiFiveHourPercent: 12, zaiWeeklyPercent: 0
167250 )
168251 XCTAssertEqual(title, "🤖12%")
169252 }
170253
254+ func testLoadingKeepsZaiWeeklyOnScreen() {
255+ let title = ViewModel.statusBarText(
256+ balance: nil, isLoading: true,
257+ hyperConfigured: false,
258+ claudeFiveHourPercent: nil, claudeSevenDayPercent: nil,
259+ zaiFiveHourPercent: nil, zaiWeeklyPercent: 3
260+ )
261+ XCTAssertEqual(title, "📆3%")
262+ }
263+
171264 // MARK: - Claude Window Percentages
172265
173266 func testFiveHourPercentRounds() {
@@ -222,4 +315,4 @@ final class StatusBarTextTests: XCTestCase {
222315 XCTAssertNil(vm.claudeFiveHourPercent)
223316 XCTAssertNil(vm.claudeSevenDayPercent)
224317 }
225−}
318+}
UsageMenubarTests/ZaiUsageClientTests.swift+23−28
@@ -22,6 +22,18 @@ final class ZaiUsageClientTests: XCTestCase {
2222 return ZaiUsageClient(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
2323 }
2424
25+ /// Enqueues quota and subscription responses matched to their URL paths, so the
26+ /// order of arrival doesn't matter — each endpoint gets its own response.
27+ private func enqueueQuotaAndSubscription(
28+ quotaStatus: Int = 200,
29+ quotaJSON: Data,
30+ subscriptionStatus: Int = 200,
31+ subscriptionJSON: Data
32+ ) {
33+ StubURLProtocol.enqueue(.status(quotaStatus, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
34+ StubURLProtocol.enqueue(.status(subscriptionStatus, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
35+ }
36+
2537 // MARK: - Decoding
2638
2739 func testDecodeQuotaResponse() throws {
@@ -78,21 +90,6 @@ final class ZaiUsageClientTests: XCTestCase {
7890
7991 // MARK: - Fetch
8092
81− /// Helper: enqueues a quota response and a subscription response. The two endpoints
82− /// are fetched concurrently, so the order they're enqueued in is the order they'll
83− /// be dequeued — but which request gets which response depends on which arrives first
84− /// at the stub. Since the stub dequeues in FIFO order, we enqueue quota first (it's
85− /// the one whose data matters), then subscription.
86− private func enqueueQuotaAndSubscription(
87− quotaStatus: Int = 200,
88− quotaJSON: Data,
89− subscriptionStatus: Int = 200,
90− subscriptionJSON: Data
91− ) {
92− StubURLProtocol.enqueue(.status(quotaStatus, quotaJSON))
93− StubURLProtocol.enqueue(.status(subscriptionStatus, subscriptionJSON))
94− }
95−
9693 func testFetchUsageSuccess() async throws {
9794 let quotaJSON = """
9895 {"code":200,"msg":"Operation successful","success":true,"data":{"limits":[
@@ -130,12 +127,11 @@ final class ZaiUsageClientTests: XCTestCase {
130127 }
131128
132129 func testFetchUsageInvalidKey() async {
133− // Both endpoints will 401 — the quota endpoint's 401 is what surfaces.
134130 let errorJSON = """
135131 {"code":401,"msg":"token expired or incorrect","success":false}
136132 """.data(using: .utf8)!
137− StubURLProtocol.enqueue(.status(401, errorJSON))
138− StubURLProtocol.enqueue(.status(401, errorJSON))
133+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
134+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
139135
140136 let client = makeStubbedClient()
141137 do {
@@ -160,11 +156,10 @@ final class ZaiUsageClientTests: XCTestCase {
160156 {"code":200,"data":[],"success":true}
161157 """.data(using: .utf8)!
162158
163− // Quota: 500 then 200 (retry). Subscription: 200 (first try).
164− // The order is: quota(500), subscription(200), quota(200 on retry).
165− StubURLProtocol.enqueue(.status(500, Data()))
166− StubURLProtocol.enqueue(.status(200, subscriptionJSON))
167− StubURLProtocol.enqueue(.status(200, quotaJSON))
159+ // Subscription succeeds on first try. Quota fails with 500, then succeeds.
160+ StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
161+ StubURLProtocol.enqueue(.status(500, Data()), forURLPath: "/api/monitor/usage/quota/limit")
162+ StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
168163
169164 let client = makeStubbedClient()
170165 let report = try await client.fetchUsage(apiKey: "test-key")
@@ -181,9 +176,9 @@ final class ZaiUsageClientTests: XCTestCase {
181176 {"code":200,"data":[],"success":true}
182177 """.data(using: .utf8)!
183178
184− StubURLProtocol.enqueue(.status(429, Data()))
185− StubURLProtocol.enqueue(.status(200, subscriptionJSON))
186− StubURLProtocol.enqueue(.status(200, quotaJSON))
179+ StubURLProtocol.enqueue(.status(200, subscriptionJSON), forURLPath: "/api/biz/subscription/list")
180+ StubURLProtocol.enqueue(.status(429, Data()), forURLPath: "/api/monitor/usage/quota/limit")
181+ StubURLProtocol.enqueue(.status(200, quotaJSON), forURLPath: "/api/monitor/usage/quota/limit")
187182
188183 let client = makeStubbedClient()
189184 let report = try await client.fetchUsage(apiKey: "test-key")
@@ -194,8 +189,8 @@ final class ZaiUsageClientTests: XCTestCase {
194189 let errorJSON = """
195190 {"code":401,"msg":"token expired or incorrect","success":false}
196191 """.data(using: .utf8)!
197− StubURLProtocol.enqueue(.status(401, errorJSON))
198− StubURLProtocol.enqueue(.status(401, errorJSON))
192+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/monitor/usage/quota/limit")
193+ StubURLProtocol.enqueue(.status(401, errorJSON), forURLPath: "/api/biz/subscription/list")
199194
200195 let client = makeStubbedClient()
201196 do {