From 5f6a4f0a5e4e0422d808728520a9a19247d8900f Mon Sep 17 00:00:00 2001 From: turtle hermit Date: Sat, 26 Sep 2026 20:24:41 +0900 Subject: [PATCH 1/4] fix: log each unpriced model once so a missing price row is noticed (#361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model with no row in ModelPricing.table renders its cost as "Unavailable" with no other trace, so a newly released model (Opus 5.5, fixed in #354) was only noticed from a screenshot. Log each unpriced model identity once per process from LocalUsageReader.Bucket.add ("Unpriced model: — its cost shows as Unavailable until ModelPricing.table has a row"). Rows whose source already marks the cost as unavailable are not reported: a missing table row is not the cause there. Tests: the once-per-identity dedupe (normalized like the price lookup), and the wiring observed through that dedupe, since AppLog is a no-op under swift test. Fault injection: removing the report, or reporting source-unavailable rows too, each fails the wiring test. Claude-Session: https://claude.ai/code/session_01PNenigaFnwvcTs2kDND16Z Co-authored-by: turtle hermit <4173875+cte13@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- .../PokeTokenBar/Core/LocalUsageReader.swift | 3 +- Sources/PokeTokenBar/Core/ModelPricing.swift | 18 ++++++++++++ .../PokeTokenBarTests/ModelPricingTests.swift | 28 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/Sources/PokeTokenBar/Core/LocalUsageReader.swift b/Sources/PokeTokenBar/Core/LocalUsageReader.swift index bcdcbf1b1..05ecd17ef 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageReader.swift @@ -63,7 +63,8 @@ enum LocalUsageReader { cacheWrite: e.cacheWrite, cacheRead: e.cacheRead) { cost += estimate costCoverage.merge(.estimate) - } else if e.total > 0 { + } else { + if e.costUnavailable != true { ModelPricing.noteUnpriced(e.model) } costCoverage.merge(.unavailable) } } diff --git a/Sources/PokeTokenBar/Core/ModelPricing.swift b/Sources/PokeTokenBar/Core/ModelPricing.swift index 634f7d082..6f870d758 100644 --- a/Sources/PokeTokenBar/Core/ModelPricing.swift +++ b/Sources/PokeTokenBar/Core/ModelPricing.swift @@ -111,6 +111,24 @@ enum ModelPricing { + Double(output) * r.output * outputMultiplier } + /// A model the table cannot price renders as "Unavailable" with no other trace — Opus 5.5 + /// went unnoticed until a screenshot. Log each unpriced identity once per process so a new + /// model shows up in the log the first time a provider reports it. + static func noteUnpriced(_ model: String) { + guard firstUnpricedSighting(model) else { return } + AppLog.write("Unpriced model: \(model) — its cost shows as Unavailable until ModelPricing.table has a row") + } + + /// True only the first time `model` is seen (normalized) — the dedupe behind `noteUnpriced`. + static func firstUnpricedSighting(_ model: String) -> Bool { + let key = modelKey(model) + unpricedLock.lock(); defer { unpricedLock.unlock() } + return unpricedSeen.insert(key).inserted + } + + nonisolated(unsafe) private static var unpricedSeen: Set = [] + private static let unpricedLock = NSLock() + static func cost(model: String, input: Int, output: Int, cacheWrite: Int, cacheRead: Int) -> Double { estimatedCost(model: model, input: input, output: output, cacheWrite: cacheWrite, cacheRead: cacheRead) ?? 0 diff --git a/Tests/PokeTokenBarTests/ModelPricingTests.swift b/Tests/PokeTokenBarTests/ModelPricingTests.swift index 7d06d1f2b..613eedb38 100644 --- a/Tests/PokeTokenBarTests/ModelPricingTests.swift +++ b/Tests/PokeTokenBarTests/ModelPricingTests.swift @@ -39,6 +39,34 @@ final class ModelPricingTests: XCTestCase { } } + /// An unpriced model is logged once per identity, not once per log entry — Bucket.add runs per request. + func testUnpricedModelIsReportedOnlyOnFirstSighting() { + let model = "claude-unpriced-\(UUID().uuidString.lowercased())" + XCTAssertTrue(ModelPricing.firstUnpricedSighting(model)) + XCTAssertFalse(ModelPricing.firstUnpricedSighting(model)) + XCTAssertFalse(ModelPricing.firstUnpricedSighting("ANTHROPIC/" + model.uppercased()), "normalized like the price lookup") + } + + /// The wiring: `AppLog` is a no-op under `swift test`, so observe it through the dedupe instead. + /// Aggregating an unpriced model must consume its first sighting (it was reported); a row whose + /// source says no estimate is possible must not, since a missing table row is not the cause there. + func testAggregatingAnUnpricedModelReportsItButNotASourceUnavailableRow() { + func entry(_ model: String, costUnavailable: Bool? = nil) -> LocalUsageReader.Entry { + LocalUsageReader.Entry(id: UUID().uuidString, date: Date(), localDay: "2026-09-25", model: model, + input: 10, output: 5, cacheWrite: 0, cacheRead: 0, + costUnavailable: costUnavailable) + } + let unpriced = "claude-unpriced-\(UUID().uuidString.lowercased())" + let sourceUnavailable = "claude-unpriced-\(UUID().uuidString.lowercased())" + var bucket = LocalUsageReader.Bucket() + bucket.add(entry(unpriced)) + bucket.add(entry(sourceUnavailable, costUnavailable: true)) + + XCTAssertFalse(ModelPricing.firstUnpricedSighting(unpriced), "an unpriced model was not reported") + XCTAssertTrue(ModelPricing.firstUnpricedSighting(sourceUnavailable), + "a source-unavailable row was reported as a missing price") + } + func testUnknownNamesNeverBorrowFamilyPrices() { for model in ["gpt-5.3-codex-spark", "gpt-99", "codex", "o3", "o4", "grok-codex-next", "claude-opus-4-99", "claude-fable-6", "gemini-99-pro", "custom/claude-opus-4-8", From a94b3dae4219a7bdd805ae5424201f4d7be5b97a Mon Sep 17 00:00:00 2001 From: turtle hermit Date: Sat, 26 Sep 2026 20:27:07 +0900 Subject: [PATCH 2/4] fix: stop unchanged status lines from filling the log every poll (#360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status lines that describe an unchanged state are written on every poll: uninstalled providers (phase1 recv ... today=nil), a missing Cursor login (cursor api / cursor limits: no session token), a missing codex binary, unavailable limits, provider status, the Claude desktop scan depth notice. AppLog rotates at 2 MB, so this noise pushes out the history needed to diagnose a crash or a failed sync. In one real log, `cursor api: no session token — state.vscdb: missing` alone appeared 2,658 times in three weeks. Add LogRepeatSuppressor (pure, tested) and AppLog.writeIfChanged, and use it for those 11 status lines. A changed message is written at once, an unchanged one is reaffirmed once an hour, and each provider/state has its own key so one change never lifts another's suppression. Event lines and lines whose values change every tick keep using AppLog.write. Claude-Session: https://claude.ai/code/session_01PNenigaFnwvcTs2kDND16Z Co-authored-by: turtle hermit <4173875+cte13@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- Sources/PokeTokenBar/Core/AppLog.swift | 18 ++++++ .../Core/CursorRateLimitsProvider.swift | 4 +- .../PokeTokenBar/Core/CursorUsageAPI.swift | 2 +- .../PokeTokenBar/Core/LocalUsageReader.swift | 2 +- .../Core/LogRepeatSuppressor.swift | 39 ++++++++++++ Sources/PokeTokenBar/Core/UsageStore.swift | 14 ++--- .../LogRepeatSuppressorTests.swift | 61 +++++++++++++++++++ 7 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 Sources/PokeTokenBar/Core/LogRepeatSuppressor.swift create mode 100644 Tests/PokeTokenBarTests/LogRepeatSuppressorTests.swift diff --git a/Sources/PokeTokenBar/Core/AppLog.swift b/Sources/PokeTokenBar/Core/AppLog.swift index 84e560476..10f0f7aae 100644 --- a/Sources/PokeTokenBar/Core/AppLog.swift +++ b/Sources/PokeTokenBar/Core/AppLog.swift @@ -50,6 +50,24 @@ enum AppLog { backend.writeAndFlush(formatted(message)) } + /// `write` for status lines repeated on every poll: an unchanged state is suppressed (reaffirmed + /// once per `repeatAfter`), a changed one is written at once. The decision lives in + /// `LogRepeatSuppressor`; this only adds the lock. Do not use it for lines whose values change + /// every tick (token counts, totals) or for events: nothing would be suppressed, or an event + /// would be lost. + static func writeIfChanged(_ key: String, _ message: String, repeatAfter: TimeInterval = 3600) { + guard AppEnv.isBundledApp else { return } + suppressorLock.lock() + let shouldWrite = suppressor.shouldWrite(key: key, message: message, repeatAfter: repeatAfter) + suppressorLock.unlock() + guard shouldWrite else { return } + backend.write(formatted(message)) + } + + /// A dictionary mutated from several threads (unlike a Bool flag), so it needs the lock. + nonisolated(unsafe) private static var suppressor = LogRepeatSuppressor() + private static let suppressorLock = NSLock() + static func write(_ message: String) { // 실제 .app 실행에서만 기록 — swift test / 로우 바이너리 실행이 프로덕션 로그를 오염시키지 // 않게(형제 write 경로 writeParitySnapshot·checkLimitNotifications 와 동일 가드). 테스트가 diff --git a/Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift b/Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift index 4a7a41560..9a7dff529 100644 --- a/Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift +++ b/Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift @@ -30,7 +30,7 @@ public struct CursorRateLimitsProvider: CursorLimitsProviding, Sendable { public func fetch() async throws -> CursorRateLimitStatus? { guard UsageEnvironment.value("CURSOR_USAGE_API") != "0" else { return nil } guard var token = CursorUsageAPI.sessionToken() else { - AppLog.write("cursor limits: no session token") + AppLog.writeIfChanged("cursor-limits-session", "cursor limits: no session token") return nil } @@ -71,7 +71,7 @@ public struct CursorRateLimitsProvider: CursorLimitsProviding, Sendable { private static func refreshAccessToken() async throws -> String? { guard let refreshToken = LocalAdditionalUsageReader.cursorAuthValue("cursorAuth/refreshToken")? .trimmingCharacters(in: .whitespacesAndNewlines), !refreshToken.isEmpty else { - AppLog.write("cursor limits: no refresh token") + AppLog.writeIfChanged("cursor-limits-refresh", "cursor limits: no refresh token") return nil } var request = URLRequest(url: tokenURL, timeoutInterval: 10) diff --git a/Sources/PokeTokenBar/Core/CursorUsageAPI.swift b/Sources/PokeTokenBar/Core/CursorUsageAPI.swift index 7f74e5ab1..618e63dc0 100644 --- a/Sources/PokeTokenBar/Core/CursorUsageAPI.swift +++ b/Sources/PokeTokenBar/Core/CursorUsageAPI.swift @@ -68,7 +68,7 @@ enum CursorUsageAPI { return UsageResult(entries: [], isAuthoritative: false) } guard let token = sessionToken() else { - AppLog.write("cursor api: no session token — \(LocalAdditionalUsageReader.cursorAuthDiagnostics())") + AppLog.writeIfChanged("cursor-api-session", "cursor api: no session token — \(LocalAdditionalUsageReader.cursorAuthDiagnostics())") return UsageResult(entries: [], isAuthoritative: false) } AppLog.write("cursor api: session token ready (\(token.count) chars)") diff --git a/Sources/PokeTokenBar/Core/LocalUsageReader.swift b/Sources/PokeTokenBar/Core/LocalUsageReader.swift index 05ecd17ef..51327550d 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageReader.swift @@ -253,7 +253,7 @@ enum LocalUsageReader { // 깊이로 잘렸으면 무엇을 찾았든 남긴다. `out.isEmpty` 를 조건에 넣으면 **부분 절단** // (세션 루트는 찾고 더 깊은 작업 디렉터리만 놓친 경우)이 조용히 지나가는데, 그게 가장 흔한 형태다. if prunedByDepth { - AppLog.write("claude desktop scan: depth \(maxDepth) reached under \(base.lastPathComponent), found \(out.count) root(s) — deeper roots may be missed") + AppLog.writeIfChanged("claude-desktop-scan-\(base.lastPathComponent)", "claude desktop scan: depth \(maxDepth) reached under \(base.lastPathComponent), found \(out.count) root(s) — deeper roots may be missed") } return out } diff --git a/Sources/PokeTokenBar/Core/LogRepeatSuppressor.swift b/Sources/PokeTokenBar/Core/LogRepeatSuppressor.swift new file mode 100644 index 000000000..00d1ae850 --- /dev/null +++ b/Sources/PokeTokenBar/Core/LogRepeatSuppressor.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Decides whether a repeated status line is written. A pure state machine, so it is tested with +/// fixtures (`AppLog.write` is a no-op under `swift test`, so the log file cannot be checked). +/// +/// Why: status lines that describe an unchanged state are written on every poll. In a real log +/// (19,751 lines over three weeks) `cursor api: no session token — state.vscdb: missing` alone +/// appeared 2,658 times. `AppLog` rotates at 2 MB, so this noise pushes out the history needed to +/// diagnose a crash or a failed sync. +/// +/// It suppresses repeats but never a state change: a different message is written at once. An +/// unchanged state is still written once per `repeatAfter`, so the log can tell "since when" from +/// "still true now". +struct LogRepeatSuppressor { + private var lastWritten: [String: (message: String, at: Date)] = [:] + + /// Returns true when the line should be written, and records it. + /// - key: the slot that lines share (e.g. `"cursor-limits"`). Use one key per provider or state, + /// so one provider's change does not lift another's suppression. + mutating func shouldWrite(key: String, + message: String, + now: Date = Date(), + repeatAfter: TimeInterval) -> Bool + { + guard let previous = lastWritten[key] else { + lastWritten[key] = (message, now) + return true // first observation: always written + } + if previous.message != message { + lastWritten[key] = (message, now) + return true // state change: written at once + } + if now.timeIntervalSince(previous.at) >= repeatAfter { + lastWritten[key] = (message, now) + return true // same state, periodic reaffirmation + } + return false + } +} diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index b0c374611..b9a28a0ce 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -855,7 +855,7 @@ final class UsageStore { } } for await outcome in group { - AppLog.write("phase1 recv id=\(outcome.id) today=\(outcome.today?.totalTokens.description ?? "nil") err=\(outcome.errorDescription ?? "none")") + AppLog.writeIfChanged("phase1-recv-\(outcome.id)", "phase1 recv id=\(outcome.id) today=\(outcome.today?.totalTokens.description ?? "nil") err=\(outcome.errorDescription ?? "none")") if let today = outcome.today { dailyByID[outcome.id] = today } if let err = outcome.errorDescription { failedIDs.insert(outcome.id) @@ -965,7 +965,7 @@ final class UsageStore { limits = nil limitsAvailable = false limitsAuthExpiry = nil // 조회 자체를 안 하므로 "세션 만료" 안내는 무의미 → 해제 - AppLog.write("claude limits skipped: keychain access disabled") + AppLog.writeIfChanged("claude-limits", "claude limits skipped: keychain access disabled") } else if let until = claudeLimitsBackoffUntil, Date() < until { // 429 백오프 중 — 폴링을 쉬어 rate limit 악화 방지 (버그 리포트 실측: 매분 429 재시도) AppLog.write("claude limits backoff: skipping (\(Int(until.timeIntervalSinceNow))s left)") @@ -983,7 +983,7 @@ final class UsageStore { if limits == nil { limitsAvailable = false } updateAuthExpired(from: error) applyLimitsBackoffIfRateLimited(error) - AppLog.write("limits unavailable: \(error)") + AppLog.writeIfChanged("claude-limits", "limits unavailable: \(error)") } } await refreshAdditionalClaudeLimits(allowKeychainPrompt: false) @@ -1541,7 +1541,7 @@ final class UsageStore { if case LimitsError.httpStatus(let code) = error, code == 401 || code == 403 { cursorLimitsAuthExpired = true } - AppLog.write("cursor limits unavailable: \(error)") + AppLog.writeIfChanged("cursor-limits", "cursor limits unavailable: \(error)") } } @@ -1570,7 +1570,7 @@ final class UsageStore { if case LimitsError.httpStatus(let code) = error, code == 401 || code == 403 { antigravityLimitsAuthExpired = true } - AppLog.write("antigravity limits unavailable: \(error)") + AppLog.writeIfChanged("antigravity-limits", "antigravity limits unavailable: \(error)") } } @@ -1650,7 +1650,7 @@ final class UsageStore { }.joined(separator: " | ") AppLog.write("codex limits refreshed [\(buckets)] plan=\(status.rateLimits.planType ?? "nil")") } else { - AppLog.write("codex limits skipped: codex binary not found") + AppLog.writeIfChanged("codex-limits", "codex limits skipped: codex binary not found") } } catch { AppLog.write("codex limits unavailable: \(error)") @@ -1677,7 +1677,7 @@ final class UsageStore { let fresh = await statusProvider.fetch() for (id, status) in fresh { statuses[id] = status } if !fresh.isEmpty { - AppLog.write("provider status: " + AppLog.writeIfChanged("provider-status", "provider status: " + fresh.map { "\($0.key)=\($0.value.indicator.rawValue)" }.sorted().joined(separator: " ")) } } diff --git a/Tests/PokeTokenBarTests/LogRepeatSuppressorTests.swift b/Tests/PokeTokenBarTests/LogRepeatSuppressorTests.swift new file mode 100644 index 000000000..98f76b766 --- /dev/null +++ b/Tests/PokeTokenBarTests/LogRepeatSuppressorTests.swift @@ -0,0 +1,61 @@ +import XCTest +@testable import PokeTokenBar + +final class LogRepeatSuppressorTests: XCTestCase { + private let start = Date(timeIntervalSince1970: 1_700_000_000) + private let hour: TimeInterval = 3600 + + func testFirstObservationIsAlwaysWritten() { + var suppressor = LogRepeatSuppressor() + XCTAssertTrue(suppressor.shouldWrite(key: "codex", message: "not found", + now: start, repeatAfter: hour)) + } + + func testIdenticalRepeatIsSuppressed() { + var suppressor = LogRepeatSuppressor() + _ = suppressor.shouldWrite(key: "codex", message: "not found", now: start, repeatAfter: hour) + XCTAssertFalse(suppressor.shouldWrite(key: "codex", message: "not found", + now: start.addingTimeInterval(120), repeatAfter: hour)) + } + + /// A state change is never lost: if suppression swallowed it, the log would lie. + func testAChangedMessageIsWrittenImmediately() { + var suppressor = LogRepeatSuppressor() + _ = suppressor.shouldWrite(key: "codex", message: "not found", now: start, repeatAfter: hour) + XCTAssertTrue(suppressor.shouldWrite(key: "codex", message: "found: 1.2.3", + now: start.addingTimeInterval(1), repeatAfter: hour)) + } + + /// An unchanged state is still written once per interval, so the log can tell "since when" from + /// "still true now" — and a quiet log from a dead app. + func testTheSameStateIsReaffirmedAfterTheInterval() { + var suppressor = LogRepeatSuppressor() + _ = suppressor.shouldWrite(key: "codex", message: "not found", now: start, repeatAfter: hour) + XCTAssertFalse(suppressor.shouldWrite(key: "codex", message: "not found", + now: start.addingTimeInterval(hour - 1), repeatAfter: hour)) + XCTAssertTrue(suppressor.shouldWrite(key: "codex", message: "not found", + now: start.addingTimeInterval(hour), repeatAfter: hour)) + } + + /// The interval restarts after a reaffirmation; otherwise, once passed, every repeat would pass. + func testTheIntervalRestartsAfterAReaffirmation() { + var suppressor = LogRepeatSuppressor() + _ = suppressor.shouldWrite(key: "codex", message: "x", now: start, repeatAfter: hour) + _ = suppressor.shouldWrite(key: "codex", message: "x", now: start.addingTimeInterval(hour), repeatAfter: hour) + XCTAssertFalse(suppressor.shouldWrite(key: "codex", message: "x", + now: start.addingTimeInterval(hour + 60), repeatAfter: hour)) + } + + /// Keys are independent: one provider's change must not lift another provider's suppression. + func testKeysAreIndependent() { + var suppressor = LogRepeatSuppressor() + _ = suppressor.shouldWrite(key: "codex", message: "not found", now: start, repeatAfter: hour) + _ = suppressor.shouldWrite(key: "antigravity", message: "not found", now: start, repeatAfter: hour) + + XCTAssertTrue(suppressor.shouldWrite(key: "codex", message: "found", + now: start.addingTimeInterval(1), repeatAfter: hour)) + XCTAssertFalse(suppressor.shouldWrite(key: "antigravity", message: "not found", + now: start.addingTimeInterval(1), repeatAfter: hour), + "another key's change lifted this key's suppression") + } +} From d6d24a04a79f0ac914eaaddca4aa9e5854970d4e Mon Sep 17 00:00:00 2001 From: turtle hermit Date: Sat, 26 Sep 2026 20:29:17 +0900 Subject: [PATCH 3/4] fix: stop the test suite from reading and rewriting the user's usage cache (#359) LocalUsageProvider uses LocalUsageCache.shared, whose default path is the user's real Application Support/PokeTokenBar/usage-cache.json with no gate. Any refresh() that runs the real providers during `swift test` (a store built with default providers, a detached Task that outlives its test) reads and rewrites that file. Measured with the app quit: one full suite run changed the real file's checksum and grew it from 965 KB to 1.02 MB; no other state file changed. Tests cannot catch this by injecting paths: injection covers the test that does it, not the process, so the default-path branch is invisible to them. The gate belongs in the store. AppEnv.persistsToUserLocation owns the rule (an injected path is always live; the default path only in the app bundle) so later stores reuse it instead of copying it. Reads are gated as well as writes, so a test cannot assert on fixtures mixed with the user's data. Verified by fault injection: removing the read gate loads 156 real blobs next to the single fixture; removing the write gate changes the real file's mtime. With the fix, a full run leaves every state file unchanged. Claude-Session: https://claude.ai/code/session_01PNenigaFnwvcTs2kDND16Z Co-authored-by: turtle hermit <4173875+cte13@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) --- Sources/PokeTokenBar/Core/AppEnv.swift | 9 ++++ .../PokeTokenBar/Core/LocalUsageCache.swift | 19 +++++++- .../LocalUsageCacheTests.swift | 43 +++++++++++++++++++ .../UsageEnvironmentTests.swift | 19 ++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/Sources/PokeTokenBar/Core/AppEnv.swift b/Sources/PokeTokenBar/Core/AppEnv.swift index 40083ddd5..8277fa5e4 100644 --- a/Sources/PokeTokenBar/Core/AppEnv.swift +++ b/Sources/PokeTokenBar/Core/AppEnv.swift @@ -28,4 +28,13 @@ enum AppEnv { static var allowsLiveLimitsFetch: Bool { isBundledApp || isParityRun || allowLiveFetchForTesting } + + /// Whether a store may read and write its file. Always true for an injected path, which is how + /// tests check persistence against a temporary file. A store that fell back to its default + /// (user) path persists only in the app bundle, so `swift test` never reads or rewrites the + /// user's real files there. Keep this rule here instead of copying it into each store. + static func persistsToUserLocation(injectedFileURL: URL?, + isBundledApp: Bool = AppEnv.isBundledApp) -> Bool { + injectedFileURL != nil || isBundledApp + } } diff --git a/Sources/PokeTokenBar/Core/LocalUsageCache.swift b/Sources/PokeTokenBar/Core/LocalUsageCache.swift index 210fc9e49..aa52294b1 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageCache.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageCache.swift @@ -122,6 +122,9 @@ actor LocalUsageCache { private let piRoots: [URL]? private let ompRoots: [URL]? private let fileURL: URL + /// Whether this cache reads and writes `fileURL` (`AppEnv.persistsToUserLocation`). Not private so + /// tests can check the gate without doing any IO. + nonisolated let persistsToDisk: Bool private let now: @Sendable () -> Date /// throwing probe 를 쓴다 — 읽기 실패(throw)와 "metadata 없음"(`nil`)은 인덱스에 남길지가 다르다. private let codexProbe: @Sendable (URL) throws -> String? @@ -153,6 +156,9 @@ actor LocalUsageCache { self.piRoots = piRoots self.ompRoots = ompRoots self.fileURL = fileURL ?? Self.defaultFileURL + // `LocalUsageProvider` uses `.shared`, so without this a single `refresh()` during `swift test` + // reads and rewrites the user's real usage-cache.json. + self.persistsToDisk = AppEnv.persistsToUserLocation(injectedFileURL: fileURL) self.now = now self.codexProbe = codexProbe } @@ -414,10 +420,20 @@ actor LocalUsageCache { // MARK: 영속화 + /// Number of loaded blobs, used to observe the read gate: if a default-path cache read the user's + /// real file, hundreds of their blobs would show up next to a single fixture. + var cachedBlobCount: Int { + ensureLoaded() + return claudeCache.count + codexCache.count + geminiCache.count + + grokCache.count + piCache.count + ompCache.count + } + private func ensureLoaded() { guard !loaded else { return } loaded = true - guard let raw = try? Data(contentsOf: fileURL) else { return } + // Reads are gated too: a test that read the user's real cache would assert on fixtures mixed + // with live data. + guard persistsToDisk, let raw = try? Data(contentsOf: fileURL) else { return } // zlib 압축 스냅샷(현행) → 실패 시 평문 JSON(구버전 캐시) 폴백 let data = (try? (raw as NSData).decompressed(using: .zlib) as Data) ?? raw guard let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return } @@ -471,6 +487,7 @@ actor LocalUsageCache { /// 변경이 있으면 디스크에 저장(최소 60초 간격으로 throttle — 잦은 쓰기 방지). private func saveIfNeeded() { guard dirty else { return } + guard persistsToDisk else { dirty = false; return } if let last = lastSave, now().timeIntervalSince(last) < 60 { return } prune() let snap = Snapshot( diff --git a/Tests/PokeTokenBarTests/LocalUsageCacheTests.swift b/Tests/PokeTokenBarTests/LocalUsageCacheTests.swift index 4352eba41..c3e4bf42e 100644 --- a/Tests/PokeTokenBarTests/LocalUsageCacheTests.swift +++ b/Tests/PokeTokenBarTests/LocalUsageCacheTests.swift @@ -127,6 +127,49 @@ final class LocalUsageCacheTests: XCTestCase { return url } + // MARK: User file isolation (regression) + + /// A cache on the default path must neither read nor write the user's real usage-cache.json. + /// + /// Every test that touches `LocalUsageCache` injects a `fileURL`, but that only covers that test: + /// `LocalUsageProvider` uses `.shared`, so any `refresh()` that runs the real providers during + /// `swift test` (a store built with default providers, a detached Task outliving its test) reaches + /// the default path. Measured: one full suite run rewrote the real file. So the guard is the store's + /// own gate, not "tests inject a path". + func testDefaultPathCacheNeitherReadsNorWritesUserFile() async throws { + _ = try writeFile("a.jsonl", lines: [claudeLine(id: "1", output: 42)]) + let cache = LocalUsageCache(claudeRoot: root) // no fileURL: the default (user) path + + XCTAssertFalse(cache.persistsToDisk, "swift test is not an app bundle, so the default path stays inert") + + // Sample the mtime before the *first* scan: a second call returns early (dirty == false) before + // the save throttle, so sampling after it would pass even with the write gate removed. + let userFile = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("PokeTokenBar/usage-cache.json") + func userFileMtime() -> Date? { + (try? FileManager.default.attributesOfItem(atPath: userFile.path))?[.modificationDate] as? Date + } + let before = userFileMtime() + + // The gate only stops IO: parsing and totals still work. + let entries = await cache.claudeEntries(modifiedSince: Date(timeIntervalSince1970: 0)) + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries.first?.output, 42, "only the fixture root counts, not the user's real cache") + + let blobs = await cache.cachedBlobCount + XCTAssertEqual(blobs, 1, "with the read gate open, the user's real blobs would show up here") + XCTAssertEqual(before, userFileMtime(), "the user's usage-cache.json was rewritten") + } + + /// The opposite direction: an injected path must still persist, or every persistence test is void. + func testInjectedPathCacheStillPersists() async throws { + _ = try writeFile("a.jsonl", lines: [claudeLine(id: "1", output: 7)]) + let cache = LocalUsageCache(claudeRoot: root, fileURL: cacheFile) + XCTAssertTrue(cache.persistsToDisk) + _ = await cache.claudeEntries(modifiedSince: Date(timeIntervalSince1970: 0)) + XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFile.path), "the injected path stays live") + } + private func makeCache(now: @escaping @Sendable () -> Date = Date.init, probes: ProbeCounter? = nil, probe: (@Sendable (URL) throws -> String?)? = nil, diff --git a/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift b/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift index 9c88bb54f..c04d8bcec 100644 --- a/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift +++ b/Tests/PokeTokenBarTests/UsageEnvironmentTests.swift @@ -202,3 +202,22 @@ final class UsageEnvironmentTests: XCTestCase { XCTAssertTrue(missing.isEmpty, "names 에 없는 조회: \(missing.joined(separator: ", "))") } } + +/// The single rule for whether a store may touch its default (user) file. +final class UserLocationGateTests: XCTestCase { + func testInjectedPathIsAlwaysLive() { + let tmp = URL(fileURLWithPath: "/tmp/whatever.json") + XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: tmp, isBundledApp: false)) + XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: tmp, isBundledApp: true)) + } + + func testDefaultPathIsInertUnlessBundled() { + XCTAssertFalse(AppEnv.persistsToUserLocation(injectedFileURL: nil, isBundledApp: false)) + XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: nil, isBundledApp: true)) + } + + /// `swift test` is not an app bundle; if that ever changes, the gate above protects nothing. + func testTestBinaryIsNotABundledApp() { + XCTAssertFalse(AppEnv.isBundledApp) + } +} From b32717fb0f4a3bd8891384fab882f580704aa44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=98=A4=ED=8B=B0=EC=8A=A4=28otis=2Esh=29?= Date: Sat, 26 Sep 2026 20:31:08 +0900 Subject: [PATCH 4/4] feat(menubar): color limit percentages like the popover gauges (#357) The menu bar limit items now use the same color as their popover gauge: the six pace tiers when the window's pace is known (Claude 5-hour, the Codex primary window), the warn/crit threshold colors otherwise (Antigravity, Cursor). Only each "Label NN%" item is colored; separators and the token/cost line keep the system text color. - PaceTier.gauge is the single rule; the popover's quotaRow uses it too, so the two surfaces cannot disagree. - Settings > Menu bar > Percent color (shown with Limit %): Match gauges (default), Only when fast, or Off. - The colors are recomputed on every refresh, so a tier that moves with the clock redraws even when the text stays the same. No new timer. --- Sources/PokeTokenBar/Core/Localization.swift | 22 +++++ Sources/PokeTokenBar/Core/PaceTier.swift | 13 +++ Sources/PokeTokenBar/Core/UsageStore.swift | 67 +++++++++++++-- Sources/PokeTokenBar/PokeTokenBarApp.swift | 26 ++++-- .../PokeTokenBar/UI/MenuLimitColoring.swift | 16 ++++ Sources/PokeTokenBar/UI/PopoverView.swift | 7 +- Sources/PokeTokenBar/UI/SettingsView.swift | 17 ++++ .../MenuLimitColoringTests.swift | 42 ++++++++++ Tests/PokeTokenBarTests/PaceTierTests.swift | 33 ++++++++ Tests/PokeTokenBarTests/UsageStoreTests.swift | 83 +++++++++++++++++++ 10 files changed, 310 insertions(+), 16 deletions(-) create mode 100644 Sources/PokeTokenBar/UI/MenuLimitColoring.swift create mode 100644 Tests/PokeTokenBarTests/MenuLimitColoringTests.swift diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index e170527b0..7038f0ab4 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -258,6 +258,28 @@ struct L { var todayTokensShort: String { t("오늘 토큰", "Today's tokens", "本日のトークン", "Tokens de hoy", "Tokens du jour", "Tokens de hoje", "Heutige Tokens") } var todayCost: String { t("오늘 비용 ($)", "Today's cost ($)", "本日のコスト ($)", "Coste de hoy ($)", "Coût du jour ($)", "Custo de hoje ($)", "Heutige Kosten ($)") } var limitPercent: String { t("한도 %", "Limit %", "上限 %", "Límite %", "Limite %", "Limite %", "Limit %") } + var menuLimitColor: String { + t("퍼센트 색상", "Percent color", "パーセントの色", "Color del porcentaje", "Couleur du pourcentage", + "Cor da porcentagem", "Prozentfarbe") + } + var menuLimitColorHint: String { + t("팝오버 한도 게이지와 같은 색", "Same colors as the popover limit gauges", + "ポップオーバーの上限ゲージと同じ色", "Los mismos colores que los medidores de límite", + "Les mêmes couleurs que les jauges de limite", "As mesmas cores dos medidores de limite", + "Dieselben Farben wie die Limit-Balken") + } + func menuLimitColorMode(_ mode: UsageStore.MenuLimitColorMode) -> String { + switch mode { + case .gauge: + return t("게이지와 같게", "Match gauges", "ゲージと同じ", "Como los medidores", + "Comme les jauges", "Como os medidores", "Wie die Balken") + case .attention: + return t("주의 단계만", "Only when fast", "速いときだけ", "Solo si va rápido", + "Seulement si rapide", "Só quando rápido", "Nur wenn schnell") + case .off: + return t("끔", "Off", "オフ", "Desactivado", "Désactivé", "Desligado", "Aus") + } + } var animationQualityLabel: String { t("애니메이션", "Animation", "アニメーション", "Animación", "Animation", "Animação", "Animation") } var animationQualityHint: String { t("부드러울수록 배터리를 더 씁니다", "Smoother uses more battery", diff --git a/Sources/PokeTokenBar/Core/PaceTier.swift b/Sources/PokeTokenBar/Core/PaceTier.swift index e78926a75..01128ab72 100644 --- a/Sources/PokeTokenBar/Core/PaceTier.swift +++ b/Sources/PokeTokenBar/Core/PaceTier.swift @@ -29,4 +29,17 @@ enum PaceTier: Int, CaseIterable { static func roundedDelta(utilization: Double, pace: Double) -> Int { Int((utilization - pace * 100).rounded()) } + + /// The tier a quota gauge is drawn in, with or without a pace. Rows without a pace (or still in the + /// early-window hold) map the absolute thresholds onto the same palette — green, orange from warn, + /// red from crit, the colors `limitColor` uses — so the popover gauge and the menu bar share one rule. + static func gauge(utilization: Double, pace: Double?, warnThreshold: Double, critThreshold: Double) -> PaceTier { + if let tier = tier(utilization: utilization, pace: pace, critThreshold: critThreshold) { return tier } + if utilization >= critThreshold { return .wayOver } + if utilization >= warnThreshold { return .over } + return .onPace + } + + /// On pace or slower — nothing to act on yet. + var isCalm: Bool { rawValue <= PaceTier.onPace.rawValue } } diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index b9a28a0ce..544275520 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -306,22 +306,72 @@ final class UsageStore { /// %는 limitDisplayMode 를 따르되 접미사 없음 — 좁은 표면이고 방향은 사용자가 고른 설정이 말해 준다 /// (배터리 메뉴바 % 관례). 자기설명 접미사("남음")는 팝오버 행에서만. private var menuLimitLine: String? { - guard showLimitInMenu else { return nil } + let parts = menuLimitParts() + return parts.isEmpty ? nil : parts.map(\.text).joined(separator: " · ") + } + + /// One menu bar limit item: its text plus what its color is judged from. + struct MenuLimitPart: Equatable { + var text: String + var utilization: Double + /// Elapsed fraction of the window; nil when its reset time or length is unknown. + var pace: Double? + } + + func menuLimitParts(now: Date = Date()) -> [MenuLimitPart] { + guard showLimitInMenu else { return [] } let usedToday = Set(snapshots.filter { $0.todayTotalTokens > 0 }.map(\.providerID)) - var parts: [String] = [] - if let utilization = menuClaudeAccount?.status.fiveHour?.utilization { - parts.append("Claude \(TokenFormatter.percent(limitDisplayPercent(utilization)))") + var parts: [MenuLimitPart] = [] + func add(_ label: String, _ utilization: Double, reset: Date? = nil, span: TimeInterval? = nil) { + let pace = reset.flatMap { reset in span.flatMap { Self.paceFraction(resetsAt: reset, span: $0, now: now) } } + parts.append(MenuLimitPart(text: "\(label) \(TokenFormatter.percent(limitDisplayPercent(utilization)))", + utilization: utilization, pace: pace)) + } + if let window = menuClaudeAccount?.status.fiveHour, let utilization = window.utilization { + add("Claude", utilization, reset: window.resetDate, span: LimitWindowSpan.fiveHour) } if usedToday.contains("codex"), let usedPercent = codexLimits?.maxPrimaryUsedPercent { - parts.append("Codex \(TokenFormatter.percent(limitDisplayPercent(Double(usedPercent))))") + // The window the percentage came from, so the pace belongs to the same bucket. + let window = codexLimits?.visibleSnapshots.compactMap(\.primary).first { $0.usedPercent == usedPercent } + add("Codex", Double(usedPercent), reset: window?.resetDate, span: window?.windowSpan) } if usedToday.contains("antigravity"), let usedPercent = antigravityLimits?.maxPrimaryUsedPercent { - parts.append("AGY \(TokenFormatter.percent(limitDisplayPercent(usedPercent)))") + add("AGY", usedPercent) } if usedToday.contains("cursor"), let usedPercent = cursorLimits?.planUsage?.usedPercent { - parts.append("Cursor \(TokenFormatter.percent(limitDisplayPercent(usedPercent)))") + add("Cursor", usedPercent) + } + return parts + } + + /// Which menu bar limit items are drawn in their gauge color. + enum MenuLimitColorMode: String, CaseIterable { + /// Every item, in all six tiers (the default). + case gauge + /// Only items running faster than pace; calm items keep the system text color. + case attention + /// None — the system text color, as before. + case off + } + var menuLimitColorMode: MenuLimitColorMode { + didSet { defaults.set(menuLimitColorMode.rawValue, forKey: "menuLimitColorMode") } + } + + /// A menu bar limit item and the tier its whole text is colored in. + struct MenuLimitColorRun: Equatable { + var text: String + var tier: PaceTier + } + + /// The colored items, left to right. Separators and the token/cost line are never included. + func menuLimitColorRuns(now: Date = Date()) -> [MenuLimitColorRun] { + guard menuLimitColorMode != .off else { return [] } + return menuLimitParts(now: now).compactMap { part in + let tier = PaceTier.gauge(utilization: part.utilization, pace: part.pace, + warnThreshold: warnThreshold, critThreshold: critThreshold) + if menuLimitColorMode == .attention && tier.isCalm { return nil } + return MenuLimitColorRun(text: part.text, tier: tier) } - return parts.isEmpty ? nil : parts.joined(separator: " · ") } /// The account whose 5h percentage the menu bar shows, nil when it shows none. @@ -716,6 +766,7 @@ final class UsageStore { showCostInMenu = d.object(forKey: "showCostInMenu") as? Bool ?? false showLimitInMenu = d.object(forKey: "showLimitInMenu") as? Bool ?? false limitDisplayMode = LimitDisplayMode(rawValue: d.string(forKey: "limitDisplayMode") ?? "") ?? .used + menuLimitColorMode = MenuLimitColorMode(rawValue: d.string(forKey: "menuLimitColorMode") ?? "") ?? .gauge limitNotifications = d.object(forKey: "limitNotifications") as? Bool ?? true companionNotifications = d.object(forKey: "companionNotifications") as? Bool ?? true updateNotificationsEnabled = d.object(forKey: "updateNotificationsEnabled") as? Bool ?? true diff --git a/Sources/PokeTokenBar/PokeTokenBarApp.swift b/Sources/PokeTokenBar/PokeTokenBarApp.swift index e0e9e2da6..de12ac17e 100644 --- a/Sources/PokeTokenBar/PokeTokenBarApp.swift +++ b/Sources/PokeTokenBar/PokeTokenBarApp.swift @@ -119,6 +119,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { withObservationTracking { _ = store.menuTitle _ = store.menuToolTip + // The mode and thresholds can change the colors while the text stays the same. + _ = store.menuLimitColorRuns() } onChange: { [weak self] in Task { @MainActor in guard let self else { return } @@ -168,7 +170,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private func applyState() { guard let button = statusItem.button else { return } - Self.applyMenuText(store.menuLines, to: button) + // Recomputed on every refresh, so a tier that moves with the clock redraws even when the text doesn't. + Self.applyMenuText(store.menuLines, to: button, colors: store.menuLimitColorRuns()) button.toolTip = store.menuToolTip needsSpriteLayout = true // 텍스트 길이가 바뀌면 버튼 폭이 변해 이미지 자리도 움직인다 // stale 시각 dim 제거 — 슬립/런치 직후 refresh 완료 전 몇 초간 회색으로 보여 '고장/비활성' @@ -184,9 +187,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { } /// 메뉴바 버튼 텍스트 반영 — 1줄이면 기본 title(13pt), 2줄 이상이면 세로 스택. - /// 줄 수에 맞춰 폰트를 자동 축소해 N줄이 메뉴바 높이에 클리핑 없이 들어오게 한다. 색을 지정하지 - /// 않아 메뉴바 명암(라이트/다크)·비활성(appearsDisabled) 상태에 자동 적응한다. - private static func applyMenuText(_ lines: [String], to button: NSStatusBarButton) { + /// 줄 수에 맞춰 폰트를 자동 축소해 N줄이 메뉴바 높이에 클리핑 없이 들어오게 한다. 한도 항목 색 + /// (`colors`) 외에는 색을 지정하지 않아 메뉴바 명암(라이트/다크)·비활성(appearsDisabled) 상태에 자동 적응한다. + private static func applyMenuText(_ lines: [String], to button: NSStatusBarButton, + colors: [UsageStore.MenuLimitColorRun]) { if lines.count >= 2 { // NSStatusBarButton 은 멀티라인 title 을 세로 중앙에 두지 않고 위로 치우쳐 그린다(측정: // titleRect.y 가 음수 → 상단 클리핑 + 하단 여백, 사용자 지적). 그래서 baselineOffset 을 @@ -203,9 +207,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { para.alignment = .center para.minimumLineHeight = effLH para.maximumLineHeight = effLH - return NSAttributedString( + let title = NSMutableAttributedString( string: lines.joined(separator: "\n"), attributes: [.font: font, .paragraphStyle: para, .baselineOffset: offset]) + MenuLimitColoring.apply(colors, to: title) + return title } let bounds = button.bounds if bounds.height > 1 { @@ -221,7 +227,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // 1줄로 되돌릴 때 이전 attributedTitle 이 남지 않게 먼저 비운다. button.attributedTitle = NSAttributedString(string: "") let title = lines.first ?? "" - button.title = title.isEmpty ? "" : " " + title + if !colors.isEmpty, !title.isEmpty { + let colored = NSMutableAttributedString( + string: " " + title, + attributes: [.font: button.font ?? NSFont.menuBarFont(ofSize: 0)]) + MenuLimitColoring.apply(colors, to: colored) + button.attributedTitle = colored + } else { + button.title = title.isEmpty ? "" : " " + title + } } } diff --git a/Sources/PokeTokenBar/UI/MenuLimitColoring.swift b/Sources/PokeTokenBar/UI/MenuLimitColoring.swift new file mode 100644 index 000000000..1107f0362 --- /dev/null +++ b/Sources/PokeTokenBar/UI/MenuLimitColoring.swift @@ -0,0 +1,16 @@ +import AppKit + +/// Colors the menu bar's limit items in their gauge color. +enum MenuLimitColoring { + /// Each run colors one whole "Label NN%" item. Labels are unique per provider and the token/cost + /// line never contains one, so a plain search finds the item. Separators and the token/cost line are + /// left alone and keep the system text color, which follows the menu bar's light/dark appearance. + static func apply(_ runs: [UsageStore.MenuLimitColorRun], to string: NSMutableAttributedString) { + let text = string.string as NSString + for run in runs { + let range = text.range(of: run.text) + guard range.location != NSNotFound else { continue } + string.addAttribute(.foregroundColor, value: NSColor(run.tier.color), range: range) + } + } +} diff --git a/Sources/PokeTokenBar/UI/PopoverView.swift b/Sources/PokeTokenBar/UI/PopoverView.swift index 8f0a05df7..73d4b5c43 100644 --- a/Sources/PokeTokenBar/UI/PopoverView.swift +++ b/Sources/PokeTokenBar/UI/PopoverView.swift @@ -640,8 +640,11 @@ struct PopoverView: View { let pace = paceFraction(reset: reset, span: span) // 페이스가 있으면 페이스 대비 단계색, 없거나 창 초반 보류 중이면 기존 절대 임계색. let tier = PaceTier.tier(utilization: utilization, pace: pace, critThreshold: store.critThreshold) - let tint = tier?.color ?? limitColor(utilization) - let percentTint = tier?.percentColor ?? limitColor(utilization) + // Same rule as the menu bar items (`UsageStore.menuLimitColorRuns`). + let gauge = PaceTier.gauge(utilization: utilization, pace: pace, + warnThreshold: store.warnThreshold, critThreshold: store.critThreshold) + let tint = gauge.color + let percentTint = gauge.percentColor return VStack(alignment: .leading, spacing: 2) { HStack { Text(name).font(.callout) diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index 253b35e28..4b03035a8 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -264,6 +264,23 @@ struct SettingsView: View { toggleRow(l.todayCost, $store.showCostInMenu) Divider() toggleRow(l.limitPercent, $store.showLimitInMenu) + if store.showLimitInMenu { + Divider() + groupRow { + VStack(alignment: .leading, spacing: 1) { + Text(l.menuLimitColor) + Text(l.menuLimitColorHint).font(.caption2).foregroundStyle(.tertiary) + } + Spacer() + Picker(l.menuLimitColor, selection: $store.menuLimitColorMode) { + ForEach(UsageStore.MenuLimitColorMode.allCases, id: \.self) { + Text(l.menuLimitColorMode($0)).tag($0) + } + } + .labelsHidden() + .fixedSize() + } + } } Text(l.allOffHint).font(.caption2).foregroundStyle(.tertiary).padding(.leading, 4) } diff --git a/Tests/PokeTokenBarTests/MenuLimitColoringTests.swift b/Tests/PokeTokenBarTests/MenuLimitColoringTests.swift new file mode 100644 index 000000000..26a548d39 --- /dev/null +++ b/Tests/PokeTokenBarTests/MenuLimitColoringTests.swift @@ -0,0 +1,42 @@ +import XCTest +import AppKit +@testable import PokeTokenBar + +final class MenuLimitColoringTests: XCTestCase { + private func color(_ string: NSAttributedString, at location: Int) -> NSColor? { + string.attribute(.foregroundColor, at: location, effectiveRange: nil) as? NSColor + } + + /// Only the items are colored — not the separator and not the token/cost line above them. + func testColorsWholeItemsAndNothingElse() { + let title = NSMutableAttributedString(string: "1.2M · $3.40\nClaude 40% · Codex 70%") + MenuLimitColoring.apply([.init(text: "Claude 40%", tier: .onPace), .init(text: "Codex 70%", tier: .over)], + to: title) + let text = title.string as NSString + let claude = text.range(of: "Claude 40%") + let codex = text.range(of: "Codex 70%") + + XCTAssertNil(color(title, at: 0), "token/cost line keeps the system color") + XCTAssertEqual(color(title, at: claude.location), NSColor(PaceTier.onPace.color)) + XCTAssertEqual(color(title, at: NSMaxRange(claude) - 1), NSColor(PaceTier.onPace.color)) + XCTAssertNil(color(title, at: NSMaxRange(claude) + 1), "separator keeps the system color") + XCTAssertEqual(color(title, at: codex.location), NSColor(PaceTier.over.color)) + XCTAssertEqual(color(title, at: text.length - 1), NSColor(PaceTier.over.color)) + } + + /// Attention mode leaves out calm items; the colored item still gets its own range. + func testASkippedItemDoesNotShiftTheNextOne() { + let title = NSMutableAttributedString(string: "Claude 40% · Codex 70%") + MenuLimitColoring.apply([.init(text: "Codex 70%", tier: .over)], to: title) + XCTAssertNil(color(title, at: 0)) + XCTAssertEqual(color(title, at: (title.string as NSString).range(of: "Codex").location), + NSColor(PaceTier.over.color)) + } + + func testARunWhoseTextIsMissingIsIgnored() { + let title = NSMutableAttributedString(string: "Claude 40%") + MenuLimitColoring.apply([.init(text: "Codex 70%", tier: .over), .init(text: "Claude 40%", tier: .onPace)], + to: title) + XCTAssertEqual(color(title, at: 0), NSColor(PaceTier.onPace.color)) + } +} diff --git a/Tests/PokeTokenBarTests/PaceTierTests.swift b/Tests/PokeTokenBarTests/PaceTierTests.swift index 98fb63fe2..cc7053b6d 100644 --- a/Tests/PokeTokenBarTests/PaceTierTests.swift +++ b/Tests/PokeTokenBarTests/PaceTierTests.swift @@ -73,3 +73,36 @@ final class PaceTierTests: XCTestCase { } } } + +/// The gauge color shared by the popover row and the menu bar. +final class PaceTierGaugeTests: XCTestCase { + private func gauge(_ utilization: Double, pace: Double?) -> PaceTier { + PaceTier.gauge(utilization: utilization, pace: pace, warnThreshold: 80, critThreshold: 95) + } + + func testAKnownPaceUsesTheSixTiers() { + XCTAssertEqual(gauge(20, pace: 0.5), .wayUnder) + XCTAssertEqual(gauge(70, pace: 0.5), .over) + } + + /// No pace, or the early-window hold: both sides of warn and crit. + func testWithoutAPaceTheThresholdsPickTheColor() { + for pace in [nil, 0.05] as [Double?] { + XCTAssertEqual(gauge(79, pace: pace), .onPace, "pace=\(String(describing: pace))") + XCTAssertEqual(gauge(80, pace: pace), .over) + XCTAssertEqual(gauge(94, pace: pace), .over) + XCTAssertEqual(gauge(95, pace: pace), .wayOver) + } + } + + /// The fallback lands on the colors `limitColor` draws, so rows without a pace look as before. + func testTheFallbackTiersMatchTheThresholdColors() { + XCTAssertEqual(PaceTier.onPace.color, .green) + XCTAssertEqual(PaceTier.over.color, .orange) + XCTAssertEqual(PaceTier.wayOver.color, .red) + } + + func testCalmMeansOnPaceOrSlower() { + XCTAssertEqual(PaceTier.allCases.filter(\.isCalm), [.wayUnder, .under, .onPace]) + } +} diff --git a/Tests/PokeTokenBarTests/UsageStoreTests.swift b/Tests/PokeTokenBarTests/UsageStoreTests.swift index 1175f6b21..154f09329 100644 --- a/Tests/PokeTokenBarTests/UsageStoreTests.swift +++ b/Tests/PokeTokenBarTests/UsageStoreTests.swift @@ -1301,3 +1301,86 @@ final class UsageStoreTests: XCTestCase { "Cursor-only must not render $0.00 / $0.0 in the menu bar") } } + +// MARK: 메뉴바 한도 색 + +private func claudeFiveHour(_ utilization: Double, resetsIn: TimeInterval?, now: Date) -> LimitStatus { + let reset = resetsIn.map { "\"\(ISO8601DateFormatter().string(from: now.addingTimeInterval($0)))\"" } ?? "null" + let json = "{\"five_hour\":{\"utilization\":\(utilization),\"resets_at\":\(reset)}}" + return try! JSONDecoder().decode(LimitStatus.self, from: Data(json.utf8)) +} + +private func codexPrimary(_ usedPercent: Int, resetsIn: TimeInterval, now: Date) -> CodexRateLimitStatus { + let reset = Int(now.addingTimeInterval(resetsIn).timeIntervalSince1970) + let json = "{\"rateLimits\":{\"primary\":{\"usedPercent\":\(usedPercent),\"windowDurationMins\":300,\"resetsAt\":\(reset)}}}" + return try! JSONDecoder().decode(CodexRateLimitStatus.self, from: Data(json.utf8)) +} + +extension UsageStoreTests { + /// Half of each 5-hour window has passed: Claude 40% is on pace (−10), Codex 70% is fast (+20). + private func halfwayStore(now: Date) async -> UsageStore { + let claude = FakeUsageProvider(id: "claude_code", displayName: "Claude Code", daily: todayDaily(1_000)) + let codex = FakeUsageProvider(id: "codex", displayName: "Codex", daily: todayDaily(1_000)) + let store = makeStore(providers: [claude, codex], + claude: claudeFiveHour(40, resetsIn: 2.5 * 3600, now: now), + codex: codexPrimary(70, resetsIn: 2.5 * 3600, now: now)) + store.showTokensInMenu = false + store.showCostInMenu = false + store.showLimitInMenu = true + await store.refresh(scheduleEmptyRetry: false) + return store + } + + func testMenuLimitPartsCarryThePaceOfTheirOwnWindow() async throws { + let now = Date() + let store = await halfwayStore(now: now) + let parts = store.menuLimitParts(now: now) + XCTAssertEqual(parts.map(\.text), ["Claude 40%", "Codex 70%"]) + XCTAssertEqual(try XCTUnwrap(parts[0].pace), 0.5, accuracy: 0.01) + XCTAssertEqual(try XCTUnwrap(parts[1].pace), 0.5, accuracy: 0.01) + XCTAssertEqual(store.menuTitle, "Claude 40% · Codex 70%", "the text itself is unchanged") + } + + func testMenuLimitColorRunsFollowTheMode() async { + let now = Date() + let store = await halfwayStore(now: now) + XCTAssertEqual(store.menuLimitColorMode, .gauge, "matching the gauges is the default") + XCTAssertEqual(store.menuLimitColorRuns(now: now), + [.init(text: "Claude 40%", tier: .onPace), .init(text: "Codex 70%", tier: .over)]) + + store.menuLimitColorMode = .attention + XCTAssertEqual(store.menuLimitColorRuns(now: now), [.init(text: "Codex 70%", tier: .over)], + "on pace keeps the system color") + + store.menuLimitColorMode = .off + XCTAssertEqual(store.menuLimitColorRuns(now: now), []) + } + + /// Without a reset time there is no pace, so the item takes the absolute threshold colors (warn 80, crit 95). + func testMenuLimitItemsWithoutAPaceUseTheThresholdColors() async { + let now = Date() + for (utilization, expected) in [(50.0, PaceTier.onPace), (85, .over), (96, .wayOver)] { + let claude = FakeUsageProvider(id: "claude_code", displayName: "Claude Code", daily: todayDaily(1_000)) + let store = makeStore(providers: [claude], claude: claudeFiveHour(utilization, resetsIn: nil, now: now)) + store.showLimitInMenu = true + await store.refresh(scheduleEmptyRetry: false) + XCTAssertNil(store.menuLimitParts(now: now).first?.pace) + XCTAssertEqual(store.menuLimitColorRuns(now: now).map(\.tier), [expected], "utilization=\(utilization)") + } + } + + /// The number follows "remaining", the color follows actual usage — the same split as the popover row. + func testMenuLimitColorIsJudgedFromUsageInRemainingMode() async { + let now = Date() + let store = await halfwayStore(now: now) + store.limitDisplayMode = .remaining + XCTAssertEqual(store.menuLimitColorRuns(now: now), + [.init(text: "Claude 60%", tier: .onPace), .init(text: "Codex 30%", tier: .over)]) + } + + func testMenuLimitColorModePersists() { + let store = makeStore(providers: []) + store.menuLimitColorMode = .attention + XCTAssertEqual(makeStore(providers: []).menuLimitColorMode, .attention) + } +}