import AppKit import CodexBarCore import Testing @testable import CodexBar @MainActor @Suite(.serialized) struct StatusMenuHostedSubmenuRefreshTests { @Test func `claude swap completion changes open menu readiness`() { let settings = Self.makeSettings() settings.setProviderEnabled( provider: .claude, metadata: ProviderDescriptorRegistry.descriptor(for: .claude).metadata, enabled: true) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } let before = controller.menuAdjunctReadinessSignature() store.claudeSwapRevision &+= 1 #expect(controller.menuAdjunctReadinessSignature() != before) } @Test func `status components change open menu readiness`() { let settings = Self.makeSettings() settings.statusChecksEnabled = true settings.setProviderEnabled( provider: .claude, metadata: ProviderDescriptorRegistry.descriptor(for: .claude).metadata, enabled: true) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } let before = controller.menuAdjunctReadinessSignature() store.statusComponents[.claude] = [ ProviderStatusComponent( id: "api", name: "API", indicator: .none, status: "operational"), ] #expect(controller.menuAdjunctReadinessSignature() != before) } @Test func `project source changes open menu readiness`() { let settings = Self.makeSettings() settings.costUsageEnabled = true Self.enableOnly(settings, provider: .codex) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(projectSourcePath: "/tmp/main"), provider: .codex) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } let before = controller.menuAdjunctReadinessSignature() store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(projectSourcePath: "/tmp/worktree"), provider: .codex) #expect(controller.menuAdjunctReadinessSignature() != before) } @Test func `status submenu link stays scoped to its provider`() throws { let settings = Self.makeSettings() settings.statusChecksEnabled = true let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } let submenu = NSMenu() #expect(controller.appendStatusComponentsItem( to: submenu, provider: .claude, width: StatusItemController.menuCardBaseWidth)) #expect(controller.hydrateHostedSubviewMenuIfNeeded(submenu)) let link = try #require(submenu.items.last) #expect(link.action == #selector(StatusItemController.openStatusPageFromMenuItem(_:))) #expect(link.identifier?.rawValue == UsageProvider.claude.rawValue) #expect(link.target === controller) } @Test func `storage native row preserves its plain menu title`() throws { let settings = Self.makeSettings() settings.providerStorageFootprintsEnabled = true let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) Self.seedStorageFootprint(in: store) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } let menu = NSMenu() #expect(controller.addStorageMenuCardSection( to: menu, provider: .claude, width: StatusItemController.menuCardBaseWidth)) let item = try #require(menu.items.first) #expect(item.title.hasPrefix(L("Storage"))) #expect(item.title == item.attributedTitle?.string) #expect(item.view == nil) #expect(item.isEnabled) #expect(item.submenu != nil) } @Test func `open parent menu defers data rebuild until parent tracking ends`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = true defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } let settings = Self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .claude settings.costUsageEnabled = true settings.costSummaryDisplayStyle = .both Self.enableOnlyClaude(settings) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) Self.seedClaudeSnapshots(in: store) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } controller.menuRefreshEnabledOverrideForTesting = false let menu = controller.makeMenu() controller.menuWillOpen(menu) let parentKey = ObjectIdentifier(menu) controller.openMenus[parentKey] = menu controller.menuVersions[parentKey] = controller.menuContentVersion let costItem = try #require(menu.items.first { ($0.representedObject as? String) == "menuCardCost" }) #expect(costItem.view == nil) #expect(costItem.title == StatusItemController.costMenuTitle) #expect(costItem.isEnabled) let submenu = try #require(costItem.submenu) #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) #expect(submenu.minimumWidth >= StatusItemController.menuCardBaseWidth) #expect(submenu.items.first?.view == nil) controller.menuRefreshEnabledOverrideForTesting = true controller.menuWillOpen(submenu) let submenuKey = ObjectIdentifier(submenu) #expect(controller.openMenus[submenuKey] === submenu) #expect(submenu.items.first?.view != nil) let oldParentVersion = try #require(controller.menuVersions[parentKey]) controller.invalidateMenus( refreshOpenMenus: true, deferOpenParentMenuRebuild: true) #expect(controller.menuVersions[parentKey] == oldParentVersion) controller.invalidateMenus( refreshOpenMenus: true, deferOpenParentMenuRebuild: true) #expect(controller.menuVersions[parentKey] == oldParentVersion) controller.menuDidClose(submenu) #expect(controller.openMenus[submenuKey] == nil) for _ in 0..<40 where controller.menuVersions[parentKey] != oldParentVersion { await Task.yield() } #expect(controller.menuVersions[parentKey] == oldParentVersion) controller.menuDidClose(menu) for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { await Task.yield() } if controller.menuVersions[parentKey] != controller.menuContentVersion { controller.menuWillOpen(menu) } for _ in 0..<40 where controller.menuVersions[parentKey] != controller.menuContentVersion { await Task.yield() } #expect(controller.menuVersions[parentKey] == controller.menuContentVersion) } @Test func `open hosted submenu rebuilds from unavailable placeholder when data arrives`() async { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = true defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } let settings = Self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .claude settings.costUsageEnabled = true Self.enableOnlyClaude(settings) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } controller.menuRefreshEnabledOverrideForTesting = true let submenu = controller.makeHostedSubviewPlaceholderMenu( chartID: StatusItemController.costHistoryChartID, provider: .claude, width: StatusItemController.menuCardBaseWidth) controller.menuWillOpen(submenu) let submenuKey = ObjectIdentifier(submenu) #expect(controller.openMenus[submenuKey] === submenu) #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) #expect(submenu.items.first?.view == nil) #expect(submenu.items.first?.title == "No data available") let openedVersion = controller.menuContentVersion store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) controller.invalidateMenus(refreshOpenMenus: true) for _ in 0..<40 { if controller.menuContentVersion != openedVersion, submenu.items.first?.view != nil { break } await Task.yield() } #expect(controller.menuContentVersion != openedVersion) #expect(submenu.items.first?.representedObject as? String == StatusItemController.costHistoryChartID) #expect(submenu.items.first?.view != nil) #expect(submenu.items.first?.title != "No data available") } @Test func `open hydrated provider submenu preserves identity across refresh`() throws { try self.assertHostedSubmenuPreservesIdentity( chartID: StatusItemController.costHistoryChartID, provider: .claude, seed: Self.seedClaudeSnapshots) try self.assertHostedSubmenuPreservesIdentity( chartID: StatusItemController.costHistoryChartID, provider: .openai, seed: Self.seedOpenAICostSnapshot) try self.assertHostedSubmenuPreservesIdentity( chartID: StatusItemController.usageHistoryChartID, provider: .claude, seed: Self.seedPlanUtilizationHistory) try self.assertHostedSubmenuPreservesIdentity( chartID: StatusItemController.storageBreakdownID, provider: .claude, seed: Self.seedStorageFootprint) try self.assertHostedSubmenuPreservesIdentity( chartID: StatusItemController.zaiHourlyUsageChartID, provider: .zai, seed: Self.seedZaiHourlyUsage) } @Test func `hosted chart items size to the displayed view without a throwaway controller`() throws { try self.assertHostedChartItemHeightMatchesRefresh( chartID: StatusItemController.costHistoryChartID, provider: .claude, seed: Self.seedClaudeSnapshots) { controller, submenu, width in controller.appendCostHistoryChartItem(to: submenu, provider: .claude, width: width) } try self.assertHostedChartItemHeightMatchesRefresh( chartID: StatusItemController.usageHistoryChartID, provider: .claude, seed: Self.seedPlanUtilizationHistory) { controller, submenu, width in controller.appendUsageHistoryChartItem(to: submenu, provider: .claude, width: width) } try self.assertHostedChartItemHeightMatchesRefresh( chartID: StatusItemController.storageBreakdownID, provider: .claude, seed: Self.seedStorageFootprint) { controller, submenu, width in controller.appendStorageBreakdownItem(to: submenu, provider: .claude, width: width) } } @Test func `zai chart render signature follows time range boundaries`() throws { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm" formatter.locale = Locale(identifier: "en_US_POSIX") let beforeMidnight = try #require(formatter.date(from: "2026-01-01 23:30")) let afterMidnight = try #require(formatter.date(from: "2026-01-02 00:30")) let modelUsage = ZaiModelUsageData( xTime: ["2026-01-01 23:00"], modelDataList: [ ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [100]), ]) let before = StatusItemController.zaiHourlyUsageRenderSignature( modelUsage: modelUsage, now: beforeMidnight) let after = StatusItemController.zaiHourlyUsageRenderSignature( modelUsage: modelUsage, now: afterMidnight) #expect(before != after) } @Test func `utilization chart invalidates when active account changes`() throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = true defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } let settings = Self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual Self.enableOnlyClaude(settings) settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") let accounts = settings.tokenAccounts(for: .claude) let alice = try #require(accounts.first) let bob = try #require(accounts.last) let aliceKey = try #require( UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) let bobKey = try #require( UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) Self.seedClaudeSnapshots(in: store) store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ aliceKey: [Self.makePlanHistory(usedPercent: 20)], bobKey: [Self.makePlanHistory(usedPercent: 50)], ]) settings.setActiveTokenAccountIndex(0, for: .claude) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } let submenu = controller.makeHostedSubviewPlaceholderMenu( chartID: StatusItemController.usageHistoryChartID, provider: .claude, width: StatusItemController.menuCardBaseWidth) controller.menuWillOpen(submenu) let aliceView = try #require(submenu.items.first?.view) settings.setActiveTokenAccountIndex(1, for: .claude) controller.refreshHostedSubviewMenu(submenu) let bobView = try #require(submenu.items.first?.view) #expect(bobView !== aliceView) } private func assertHostedChartItemHeightMatchesRefresh( chartID: String, provider: UsageProvider, seed: (UsageStore) -> Void, append: (StatusItemController, NSMenu, CGFloat) -> Bool) throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = true defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } let settings = Self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.costUsageEnabled = true settings.providerStorageFootprintsEnabled = true Self.enableOnly(settings, provider: provider) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) seed(store) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } let width = StatusItemController.menuCardBaseWidth let submenu = NSMenu() submenu.minimumWidth = width #expect(append(controller, submenu, width)) let item = try #require(submenu.items.first) let view = try #require(item.view) let heightFromAppend = view.frame.height // The height the append path assigns must match the authoritative re-measure pass; otherwise // dropping the throwaway NSHostingController would have changed sizing behavior. controller.refreshHostedSubviewHeights(in: submenu) #expect(view.frame.height == heightFromAppend) #expect(heightFromAppend > 1) } private func assertHostedSubmenuPreservesIdentity( chartID: String, provider: UsageProvider, seed: (UsageStore) -> Void) throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled StatusItemController.menuCardRenderingEnabled = true defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering } let settings = Self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = provider settings.costUsageEnabled = true settings.providerStorageFootprintsEnabled = true Self.enableOnly(settings, provider: provider) let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) seed(store) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } controller.menuRefreshEnabledOverrideForTesting = true let submenu = controller.makeHostedSubviewPlaceholderMenu( chartID: chartID, provider: provider, width: StatusItemController.menuCardBaseWidth) controller.menuWillOpen(submenu) let hydratedItem = try #require(submenu.items.first) #expect(hydratedItem.representedObject as? String == chartID) #expect(hydratedItem.toolTip == provider.rawValue) #expect(hydratedItem.view != nil) #expect(hydratedItem.title != "No data available") let hydratedView = hydratedItem.view let inflatedHeight = hydratedView.map { view -> CGFloat in let inflatedHeight = view.frame.height + 100 if chartID == StatusItemController.zaiHourlyUsageChartID { view.frame.size.height = inflatedHeight } return inflatedHeight } controller.refreshHostedSubviewMenu(submenu) let refreshedItem = try #require(submenu.items.first) #expect(refreshedItem.representedObject as? String == chartID) #expect(refreshedItem.toolTip == provider.rawValue) #expect(refreshedItem.view != nil) #expect(refreshedItem.title != "No data available") #expect(refreshedItem.view === hydratedView) if chartID == StatusItemController.zaiHourlyUsageChartID { #expect(refreshedItem.view?.frame.height != inflatedHeight) } if chartID == StatusItemController.costHistoryChartID, provider == .claude { store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(dailyCost: 2.34), provider: .claude) controller.refreshHostedSubviewMenu(submenu) let changedItem = try #require(submenu.items.first) #expect(changedItem.view != nil) #expect(changedItem.view !== hydratedView) } } private static func makeSettings() -> SettingsStore { let suite = "StatusMenuHostedSubmenuRefreshTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) return SettingsStore( userDefaults: defaults, configStore: testConfigStore(suiteName: suite), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) } private static func enableOnlyClaude(_ settings: SettingsStore) { self.enableOnly(settings, provider: .claude) } private static func enableOnly(_ settings: SettingsStore, provider enabledProvider: UsageProvider) { let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider) } } private static func seedClaudeSnapshots(in store: UsageStore) { let snapshot = UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), secondary: nil, tertiary: nil, updatedAt: Date(), identity: ProviderIdentitySnapshot( providerID: .claude, accountEmail: "user@example.com", accountOrganization: nil, loginMethod: "Team")) store._setSnapshotForTesting(snapshot, provider: .claude) store._setTokenSnapshotForTesting(Self.makeTokenSnapshot(), provider: .claude) } private static func seedOpenAICostSnapshot(in store: UsageStore) { let day = Date(timeIntervalSince1970: 1_700_000_000) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2025-12-23", startTime: day, endTime: day.addingTimeInterval(86400), costUSD: 1.23, requests: 12, inputTokens: 100, cachedInputTokens: 20, outputTokens: 40, totalTokens: 160, lineItems: [], models: []), ], updatedAt: Date(timeIntervalSince1970: 1_700_086_400)) let snapshot = UsageSnapshot( primary: nil, secondary: nil, tertiary: nil, openAIAPIUsage: apiUsage, updatedAt: Date(timeIntervalSince1970: 1_700_086_400), identity: ProviderIdentitySnapshot( providerID: .openai, accountEmail: "openai@example.com", accountOrganization: nil, loginMethod: "API")) store._setSnapshotForTesting(snapshot, provider: .openai) } private static func seedPlanUtilizationHistory(in store: UsageStore) { self.seedClaudeSnapshots(in: store) store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( unscoped: [ self.makePlanHistory(usedPercent: 24), ]) } private static func makePlanHistory(usedPercent: Double) -> PlanUtilizationSeriesHistory { PlanUtilizationSeriesHistory( name: .session, windowMinutes: 300, entries: [ PlanUtilizationHistoryEntry( capturedAt: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: usedPercent, resetsAt: Date(timeIntervalSince1970: 1_700_018_000)), ]) } private static func seedStorageFootprint(in store: UsageStore) { let root = "/Users/test/.claude" store.providerStorageFootprints[.claude] = ProviderStorageFootprint( provider: .claude, totalBytes: 1024, paths: [root], missingPaths: [], unreadablePaths: [], components: [.init(path: "\(root)/projects", totalBytes: 1024)], updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) } private static func seedZaiHourlyUsage(in store: UsageStore) { let modelUsage = ZaiModelUsageData( xTime: ["2026-05-26 00:00"], modelDataList: [ ZaiModelDataItem(modelName: "glm-4.5", tokensUsage: [512]), ]) let snapshot = UsageSnapshot( primary: nil, secondary: nil, tertiary: nil, zaiUsage: ZaiUsageSnapshot( tokenLimit: nil, timeLimit: nil, planName: "Pro", modelUsage: modelUsage, updatedAt: Date(timeIntervalSince1970: 1_700_000_000)), updatedAt: Date(timeIntervalSince1970: 1_700_000_000), identity: ProviderIdentitySnapshot( providerID: .zai, accountEmail: "zai@example.com", accountOrganization: nil, loginMethod: "OAuth")) store._setSnapshotForTesting(snapshot, provider: .zai) } private static func makeTokenSnapshot( dailyCost: Double = 1.23, projectSourcePath: String? = nil) -> CostUsageTokenSnapshot { let projects = projectSourcePath.map { sourcePath in [ CostUsageProjectBreakdown( name: "Project", path: "/tmp/main", totalTokens: 123, totalCostUSD: dailyCost, daily: [], modelBreakdowns: nil, sources: [ CostUsageProjectSourceBreakdown( name: "Source", path: sourcePath, totalTokens: 123, totalCostUSD: dailyCost, daily: [], modelBreakdowns: nil), ]), ] } ?? [] return CostUsageTokenSnapshot( sessionTokens: 123, sessionCostUSD: 0.12, last30DaysTokens: 123, last30DaysCostUSD: dailyCost, daily: [ CostUsageDailyReport.Entry( date: "2025-12-23", inputTokens: nil, outputTokens: nil, totalTokens: 123, costUSD: dailyCost, modelsUsed: nil, modelBreakdowns: nil), ], projects: projects, updatedAt: Date()) } }