diff --git a/Sources/CodexBar/StatusItemController+IconObservation.swift b/Sources/CodexBar/StatusItemController+IconObservation.swift index 218b1f882e..a99cf8123d 100644 --- a/Sources/CodexBar/StatusItemController+IconObservation.swift +++ b/Sources/CodexBar/StatusItemController+IconObservation.swift @@ -64,9 +64,46 @@ extension StatusItemController { "text=\(displayText ?? "nil")", "layoutCost=\(layoutCostSignature ?? "nil")", "layoutAccount=\(layoutAccountSignature ?? "nil")", + "accountSnapshots=\(self.accountSnapshotSignature(for: provider))", ].joined(separator: "|") } + private func accountSnapshotSignature(for provider: UsageProvider) -> String { + let accountSnapshots = self.store.accountSnapshots[provider] ?? [] + let accountPart = accountSnapshots.map { snap in + let label = snap.account.label + let usage = Self.usageSnapshotSignature(snap.snapshot) + return "\(snap.account.id.uuidString):\(label):\(usage):\(snap.error ?? "nil"):\(snap.sourceLabel ?? "nil")" + }.joined(separator: ",") + + let codexPart: String = if provider == .codex { + self.store.codexAccountSnapshots.map { snap in + let email = snap.account.email + let active = snap.account.isActive ? "1" : "0" + let usage = Self.usageSnapshotSignature(snap.snapshot) + return "\(snap.id):\(email):\(active):\(usage):\(snap.error ?? "nil"):\(snap.sourceLabel ?? "nil")" + }.joined(separator: ",") + } else { + "" + } + + return "\(accountPart)|\(codexPart)" + } + + private static func usageSnapshotSignature(_ snapshot: UsageSnapshot?) -> String { + guard let snapshot else { return "nil" } + return [ + Self.rateWindowSignature(snapshot.primary), + Self.rateWindowSignature(snapshot.secondary), + Self.rateWindowSignature(snapshot.tertiary), + ].joined(separator: ";") + } + + private static func rateWindowSignature(_ window: RateWindow?) -> String { + guard let window else { return "nil" } + return "\(window.usedPercent)/\(window.windowMinutes ?? -1)/\(window.resetsAt?.timeIntervalSince1970 ?? -1)" + } + private func storedMenuBarLayoutAccountSignature( for provider: UsageProvider, snapshot: UsageSnapshot?) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 19c9488717..55d4d9f72d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -56,6 +56,8 @@ extension UsageStore { _ = self.statuses _ = self.tokenSnapshotPublications _ = self.historicalPaceRevision + _ = self.accountSnapshots + _ = self.codexAccountSnapshots return 0 } diff --git a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift index 02b23dedcc..80073d460c 100644 --- a/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift +++ b/Tests/CodexBarTests/StatusItemIconObservationSignatureTests.swift @@ -294,6 +294,188 @@ struct StatusItemIconObservationSignatureTests { #expect(controller.storeIconObservationSignature() != baseline) } + @Test + func `store icon observation token invalidates when account snapshots change`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-account-snapshots") + defer { controller.releaseStatusItemsForTesting() } + + let baselineSignature = controller.storeIconObservationSignature() + + nonisolated(unsafe) var invalidated = false + withObservationTracking { + _ = store.iconObservationToken + } onChange: { + invalidated = true + } + + let account = ProviderTokenAccount( + id: UUID(), + label: "Account 1", + token: "token1", + addedAt: 1, + lastUsed: nil) + + store.accountSnapshots[.codex] = [ + TokenAccountUsageSnapshot( + account: account, + snapshot: nil, + error: nil, + sourceLabel: "fixture", + cacheKey: store.tokenAccountSnapshotCacheKey(provider: .codex, account: account)), + ] + + #expect(invalidated) + #expect(controller.storeIconObservationSignature() != baselineSignature) + } + + @Test + func `store icon observation token invalidates when codex account snapshots change`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-codex-account-snapshots") + defer { controller.releaseStatusItemsForTesting() } + + let baselineSignature = controller.storeIconObservationSignature() + + nonisolated(unsafe) var invalidated = false + withObservationTracking { + _ = store.iconObservationToken + } onChange: { + invalidated = true + } + + let visibleAccount = CodexVisibleAccount( + id: "test-codex-account", + email: "email@example.com", + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .profileHome(path: "/tmp"), + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: true) + + store.codexAccountSnapshots = [ + CodexAccountUsageSnapshot( + account: visibleAccount, + snapshot: nil, + error: nil, + sourceLabel: "cached"), + ] + + #expect(invalidated) + #expect(controller.storeIconObservationSignature() != baselineSignature) + } + + @Test + func `store icon observation signature changes when existing account snapshot usage is replaced`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-account-usage-replacement") + defer { controller.releaseStatusItemsForTesting() } + + let account = ProviderTokenAccount( + id: UUID(), + label: "Account 1", + token: "token1", + addedAt: 1, + lastUsed: nil) + let cacheKey = store.tokenAccountSnapshotCacheKey(provider: .codex, account: account) + + store.accountSnapshots[.codex] = [ + TokenAccountUsageSnapshot( + account: account, + snapshot: Self.makeSnapshot(provider: .codex, email: "account@example.com", primaryUsedPercent: 10), + error: nil, + sourceLabel: "fixture", + cacheKey: cacheKey), + ] + let baseline = controller.storeIconObservationSignature() + + store.accountSnapshots[.codex] = [ + TokenAccountUsageSnapshot( + account: account, + snapshot: Self.makeSnapshot(provider: .codex, email: "account@example.com", primaryUsedPercent: 42), + error: nil, + sourceLabel: "fixture", + cacheKey: cacheKey), + ] + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature changes when existing account snapshot error is replaced`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-account-error-replacement") + defer { controller.releaseStatusItemsForTesting() } + + let account = ProviderTokenAccount( + id: UUID(), + label: "Account 1", + token: "token1", + addedAt: 1, + lastUsed: nil) + let cacheKey = store.tokenAccountSnapshotCacheKey(provider: .codex, account: account) + + store.accountSnapshots[.codex] = [ + TokenAccountUsageSnapshot( + account: account, + snapshot: nil, + error: nil, + sourceLabel: "fixture", + cacheKey: cacheKey), + ] + let baseline = controller.storeIconObservationSignature() + + store.accountSnapshots[.codex] = [ + TokenAccountUsageSnapshot( + account: account, + snapshot: nil, + error: "rate limited", + sourceLabel: "fixture", + cacheKey: cacheKey), + ] + + #expect(controller.storeIconObservationSignature() != baseline) + } + + @Test + func `store icon observation signature changes when existing codex account snapshot is replaced`() { + let (_, store, controller) = self.makeController( + suiteName: "StatusItemIconObservationSignatureTests-codex-account-replacement") + defer { controller.releaseStatusItemsForTesting() } + + let visibleAccount = CodexVisibleAccount( + id: "test-codex-account", + email: "email@example.com", + authFingerprint: nil, + storedAccountID: nil, + selectionSource: .profileHome(path: "/tmp"), + isActive: true, + isLive: true, + canReauthenticate: true, + canRemove: true) + + store.codexAccountSnapshots = [ + CodexAccountUsageSnapshot( + account: visibleAccount, + snapshot: Self.makeSnapshot(provider: .codex, email: "email@example.com", primaryUsedPercent: 10), + error: nil, + sourceLabel: "cached"), + ] + let baseline = controller.storeIconObservationSignature() + + store.codexAccountSnapshots = [ + CodexAccountUsageSnapshot( + account: visibleAccount, + snapshot: Self.makeSnapshot(provider: .codex, email: "email@example.com", primaryUsedPercent: 66), + error: nil, + sourceLabel: "cached"), + ] + + #expect(controller.storeIconObservationSignature() != baseline) + } + @Test func `store icon observation signature changes when hide critters toggles`() { let (settings, _, controller) = self.makeController(