import AppKit import CodexBarCore import Testing @testable import CodexBar @MainActor @Suite(.serialized) struct StatusMenuSwitcherClickTests { private func makeStatusBarForTesting() -> NSStatusBar { // Use the real system status bar in tests. Creating standalone NSStatusBar instances // has caused AppKit teardown crashes under swiftpm-testing-helper. .system } private func makeSettings() -> SettingsStore { let suite = "StatusMenuSwitcherClickTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) return SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) } private func makeInstalledSwitcherShortcutMonitor() -> (controller: StatusItemController, menu: StatusItemMenu) { let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } let shouldEnable = provider == .codex || provider == .claude settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } 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: self.makeStatusBarForTesting()) let menu = StatusItemMenu() let switcher = ProviderSwitcherView( providers: [.codex, .claude], selected: .provider(.codex), includesOverview: true, width: 320, showsIcons: false, iconProvider: { _ in NSImage() }, weeklyRemainingProvider: { _ in nil }, onSelect: { _ in }) let switcherItem = NSMenuItem() switcherItem.view = switcher menu.addItem(switcherItem) menu.addItem(.separator()) controller.installProviderSwitcherShortcutMonitorIfNeeded(for: menu) return (controller, menu) } @Test func `merged switcher routes runtime clicks after overview round-trip`() throws { // Regression test for #867: after Provider → Overview, subsequent runtime clicks on a // sub-provider tab dropped through NSButton's tracking and never updated state. let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .claude settings.mergedMenuLastSelectedWasOverview = false let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } let shouldEnable = provider == .codex || provider == .claude settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } 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: self.makeStatusBarForTesting()) let menu = controller.makeMenu() controller.menuWillOpen(menu) // Step 1: provider → Overview via the runtime click path. let switcher1 = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher1._test_simulateRuntimeClick(buttonTag: 0)) #expect(settings.mergedMenuLastSelectedWasOverview == true) // Step 2: Overview → provider via the runtime click path. Tag 2 is the second provider // (claude) since tag 0 is Overview and tag 1 is the first provider. let switcher2 = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher2._test_simulateRuntimeClick(buttonTag: 2)) #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .claude) // Step 3: provider → Overview again. let switcher3 = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher3._test_simulateRuntimeClick(buttonTag: 0)) #expect(settings.mergedMenuLastSelectedWasOverview == true) // Step 4: Overview → other provider. This is the click that previously got dropped. let switcher4 = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher4._test_simulateRuntimeClick(buttonTag: 1)) #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .codex) } @Test func `merged switcher commits selection on matching mouse up`() throws { var selections: [ProviderSwitcherSelection] = [] let switcher = ProviderSwitcherView( providers: [.codex, .claude], selected: .provider(.codex), includesOverview: true, width: 320, showsIcons: false, iconProvider: { _ in NSImage() }, weeklyRemainingProvider: { _ in nil }, onSelect: { selections.append($0) }) #expect(switcher._test_simulateMouseDown(buttonTag: 0)) #expect(selections.isEmpty) let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) #expect(switcher.handleMenuTrackingMouseUp(mouseUp)) #expect(selections == [.overview]) } @Test func `menu tracking routes switcher pointer sequence before AppKit menu dispatch`() throws { var selected: ProviderSwitcherSelection? let switcher = ProviderSwitcherView( providers: [.codex, .claude], selected: .provider(.codex), includesOverview: true, width: 320, showsIcons: false, iconProvider: { _ in NSImage() }, weeklyRemainingProvider: { _ in nil }, onSelect: { selected = $0 }) let menu = StatusItemMenu() let item = NSMenuItem() item.view = switcher item.isEnabled = false menu.addItem(item) let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual let fetcher = UsageFetcher() let controller = StatusItemController( store: UsageStore( fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings), settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } let mouseDown = try #require(switcher._test_mouseDownEvent(buttonTag: 0)) let mouseUp = try #require(switcher._test_mouseUpEvent(buttonTag: 0)) #expect(controller.handleProviderSwitcherTrackingEvent(mouseDown, menu: menu)) #expect(selected == nil) #expect(controller.handleProviderSwitcherTrackingEvent(mouseUp, menu: menu)) #expect(selected == .overview) } @Test func `merged switcher runtime click defers icon rendering until after event handling`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex settings.mergedMenuLastSelectedWasOverview = false let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } let shouldEnable = provider == .codex || provider == .claude settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } 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: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } controller.applyIcon(phase: nil) #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) let menu = controller.makeMenu() controller.menuWillOpen(menu) let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) #expect(settings.selectedMenuProvider == .claude) #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) await controller.providerSelectionUIRefreshTask?.value #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) } @Test func `merged switcher click marks menu stale before deferred rebuild`() throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(true) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex settings.mergedMenuLastSelectedWasOverview = false let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } let shouldEnable = provider == .codex || provider == .claude settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } 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: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max defer { controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = nil } let menu = controller.makeMenu() controller.menuWillOpen(menu) let key = ObjectIdentifier(menu) #expect(controller.menuVersions[key] == controller.menuContentVersion) let openedVersion = controller.menuContentVersion let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) #expect(settings.selectedMenuProvider == .claude) #expect(controller.menuContentVersion != openedVersion) #expect(controller.menuVersions[key] != controller.menuContentVersion) controller.menuDidClose(menu) #expect(controller.menuVersions[key] != controller.menuContentVersion) #expect(controller.openMenuRebuildTasks[key] == nil) } @Test func `merged switcher runtime click updates loading animation state after event handling`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex settings.mergedMenuLastSelectedWasOverview = false let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } let shouldEnable = provider == .codex || provider == .claude settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: nil, updatedAt: Date()), provider: .codex) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } controller.applyIcon(phase: nil) #expect(controller.needsMenuBarIconAnimation() == false) #expect(controller.animationDriver == nil) let menu = controller.makeMenu() controller.menuWillOpen(menu) let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) #expect(settings.selectedMenuProvider == .claude) await controller.providerSelectionUIRefreshTask?.value #expect(controller.needsMenuBarIconAnimation() == true) #expect(controller.animationDriver != nil) #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=claude") == true) #expect(switcher._test_simulateRuntimeClick(buttonTag: 1)) #expect(settings.selectedMenuProvider == .codex) await controller.providerSelectionUIRefreshTask?.value #expect(controller.needsMenuBarIconAnimation() == false) #expect(controller.animationDriver == nil) #expect(controller.lastAppliedMergedIconRenderSignature?.contains("provider=codex") == true) } @Test func `merged switcher switches provider while overview chart submenu is open`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .openai settings.mergedMenuLastSelectedWasOverview = true settings.costUsageEnabled = true settings.costSummaryDisplayStyle = .both let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } let shouldEnable = provider == .openai || provider == .claude settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } let fetcher = UsageFetcher() let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let now = Date(timeIntervalSince1970: 1_700_000_000) let usage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", startTime: now, endTime: now.addingTimeInterval(86400), costUSD: 9, requests: 12, inputTokens: 100, cachedInputTokens: 0, outputTokens: 50, totalTokens: 150, lineItems: [], models: []), ], updatedAt: now) store._setSnapshotForTesting(usage.toUsageSnapshot(), provider: .openai) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } let menu = controller.makeMenu() controller.menuWillOpen(menu) controller.openMenus[ObjectIdentifier(menu)] = menu let openAIRow = try #require(menu.items.first { ($0.representedObject as? String) == "overviewRow-openai" }) let submenu = try #require(openAIRow.submenu) controller.openMenus[ObjectIdentifier(submenu)] = submenu var rebuildCount = 0 controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } defer { controller._test_openMenuRebuildObserver = nil } let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView) #expect(switcher._test_simulateRuntimeClick(buttonTag: 2)) for _ in 0..<100 where rebuildCount == 0 { await Task.yield() try? await Task.sleep(for: .milliseconds(10)) } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .claude) #expect(rebuildCount == 1) #expect(controller.openMenus[ObjectIdentifier(submenu)] == nil) let ids = menu.items.compactMap { $0.representedObject as? String } #expect(ids.contains("menuCard")) #expect(ids.contains(where: { $0.hasPrefix("overviewRow-") }) == false) } @Test func `merged switcher handles left and right arrow keyboard navigation`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex settings.mergedMenuLastSelectedWasOverview = false let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } let shouldEnable = provider == .codex || provider == .claude settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } settings.setMergedOverviewProviderSelection( provider: .codex, isSelected: false, activeProviders: [.codex, .claude]) settings.setMergedOverviewProviderSelection( provider: .claude, isSelected: false, activeProviders: [.codex, .claude]) 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: self.makeStatusBarForTesting()) controller.menuRefreshEnabledOverrideForTesting = true defer { controller.releaseStatusItemsForTesting() } let menu = try #require(controller.makeMenu() as? StatusItemMenu) controller.menuWillOpen(menu) #expect(menu.items.first?.view is ProviderSwitcherView) store.tokenRefreshInFlight.insert(.codex) defer { store.tokenRefreshInFlight.remove(.codex) } var rebuildCount = 0 controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 } defer { controller._test_openMenuRebuildObserver = nil } #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 124)) == true) for _ in 0..<100 where rebuildCount == 0 { await Task.yield() try? await Task.sleep(for: .milliseconds(5)) } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .claude) #expect(rebuildCount == 1) #expect(try menu.performKeyEquivalent(with: Self.arrowKeyEvent(keyCode: 123)) == true) for _ in 0..<100 where rebuildCount == 1 { await Task.yield() try? await Task.sleep(for: .milliseconds(5)) } #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .codex) #expect(rebuildCount == 2) } @Test func `merged switcher handles command number shortcuts in visible order`() async throws { let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.menuCardRenderingEnabled = false StatusItemController.setMenuRefreshEnabledForTesting(false) defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let settings = self.makeSettings() settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.selectedMenuProvider = .codex settings.mergedMenuLastSelectedWasOverview = false let registry = ProviderRegistry.shared for provider in UsageProvider.allCases { guard let metadata = registry.metadata[provider] else { continue } let shouldEnable = provider == .codex || provider == .claude || provider == .cursor settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: shouldEnable) } 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: self.makeStatusBarForTesting()) defer { controller.releaseStatusItemsForTesting() } let menu = try #require(controller.makeMenu() as? StatusItemMenu) controller.menuWillOpen(menu) #expect(menu.items.first?.view is ProviderSwitcherView) #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("3", keyCode: 20), menu: menu)) await Task.yield() #expect(settings.mergedMenuLastSelectedWasOverview == false) #expect(settings.selectedMenuProvider == .claude) #expect(try controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("1", keyCode: 18), menu: menu)) await Task.yield() #expect(settings.mergedMenuLastSelectedWasOverview == true) #expect(settings.selectedMenuProvider == .claude) #expect(try !controller.handleProviderSwitcherShortcut(Self.commandKeyEvent("9", keyCode: 25), menu: menu)) await Task.yield() #expect(settings.mergedMenuLastSelectedWasOverview == true) #expect(settings.selectedMenuProvider == .claude) } @Test func `provider shortcut monitor is removed when tracked menu closes after switcher rebuild`() { let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.setMenuRefreshEnabledForTesting(true) defer { StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() defer { controller.releaseStatusItemsForTesting() } #expect(controller.providerSwitcherShortcutEventMonitor != nil) #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) menu.removeAllItems() controller.menuDidClose(menu) #expect(controller.providerSwitcherShortcutEventMonitor == nil) #expect(controller.providerSwitcherShortcutMenuID == nil) } @Test func `switcher shortcut monitor is removed from direct close cleanup`() { let previousMenuRefresh = StatusItemController.menuRefreshEnabled StatusItemController.setMenuRefreshEnabledForTesting(true) defer { StatusItemController.setMenuRefreshEnabledForTesting(previousMenuRefresh) } let (controller, menu) = self.makeInstalledSwitcherShortcutMonitor() defer { controller.releaseStatusItemsForTesting() } #expect(controller.providerSwitcherShortcutEventMonitor != nil) #expect(controller.providerSwitcherShortcutMenuID == ObjectIdentifier(menu)) controller.forgetClosedMenu(menu) #expect(controller.providerSwitcherShortcutEventMonitor == nil) #expect(controller.providerSwitcherShortcutMenuID == nil) } @Test func `switcher hover styling keeps layout stable`() { let view = ProviderSwitcherView( providers: [.codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba], selected: .provider(.codex), includesOverview: true, width: 300, showsIcons: true, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { _ in nil }, onSelect: { _ in }) let initialSize = view.intrinsicContentSize let initialFrames = view._test_buttonFrames() view._test_setHoveredButtonTag(3) view._test_setHoveredButtonTag(6) view._test_setHoveredButtonTag(nil as Int?) #expect(view.intrinsicContentSize == initialSize) #expect(view._test_buttonFrames() == initialFrames) } @Test func `switcher quota indicator preserves remaining percentage`() throws { let view = ProviderSwitcherView( providers: [.claude, .grok], selected: .provider(.claude), includesOverview: false, width: 180, showsIcons: true, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { provider in switch provider { case .claude: 5 case .grok: 95 default: nil } }, onSelect: { _ in }) let fillRatios = view._test_quotaIndicatorFillRatios() #expect(fillRatios.count == 2) let lowRemainingRatio = try #require(fillRatios.first) let highRemainingRatio = try #require(fillRatios.last) #expect(lowRemainingRatio < highRemainingRatio) } @Test func `switcher quota indicator refresh updates fill ratios`() throws { var claudeRemaining = 5.0 var grokRemaining = 95.0 let view = ProviderSwitcherView( providers: [.claude, .grok], selected: .provider(.claude), includesOverview: false, width: 180, showsIcons: true, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { provider in switch provider { case .claude: claudeRemaining case .grok: grokRemaining default: nil } }, onSelect: { _ in }) let initialRatios = view._test_quotaIndicatorFillRatios() let initialLow = try #require(initialRatios.first) let initialHigh = try #require(initialRatios.last) claudeRemaining = 80 grokRemaining = 12 view.updateQuotaIndicators() let updatedRatios = view._test_quotaIndicatorFillRatios() let updatedLow = try #require(updatedRatios.first) let updatedHigh = try #require(updatedRatios.last) #expect(updatedLow > initialLow) #expect(updatedHigh < initialHigh) } @Test func `switcher quota indicator renders zero remaining empty`() { var grokRemaining = 50.0 let view = ProviderSwitcherView( providers: [.claude, .grok], selected: .provider(.claude), includesOverview: false, width: 180, showsIcons: true, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { provider in switch provider { case .claude: 100 case .grok: grokRemaining default: nil } }, onSelect: { _ in }) grokRemaining = 0 view.updateQuotaIndicators() view.updateConstraintsForSubtreeIfNeeded() view.layoutSubtreeIfNeeded() let fillRatios = view._test_quotaIndicatorFillRatios() let fillFrames = view._test_quotaIndicatorFillFrames() #expect(fillRatios.last == 0) #expect(fillFrames.last?.width == 0) } @Test func `switcher keeps stable height when remaining becomes unavailable`() throws { var grokRemaining: Double? = 50 let noQuotaView = ProviderSwitcherView( providers: [.claude, .grok], selected: .provider(.claude), includesOverview: false, width: 180, showsIcons: true, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { _ in nil }, onSelect: { _ in }) let view = ProviderSwitcherView( providers: [.claude, .grok], selected: .provider(.claude), includesOverview: false, width: 180, showsIcons: true, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { provider in switch provider { case .claude: 100 case .grok: grokRemaining default: nil } }, onSelect: { _ in }) #expect(view._test_quotaIndicatorFillRatios().count == 2) let noQuotaHeight = try #require(noQuotaView._test_buttonFittingSizes().last?.height) let quotaHeight = try #require(view._test_buttonFittingSizes().last?.height) #expect(quotaHeight == noQuotaHeight) grokRemaining = nil view.updateQuotaIndicators() #expect(view._test_quotaIndicatorFillRatios().count == 1) let removedQuotaHeight = try #require(view._test_buttonFittingSizes().last?.height) #expect(removedQuotaHeight == noQuotaHeight) } @Test func `text only switcher keeps stable height with quota bars`() throws { let providers: [UsageProvider] = [.claude, .grok] let textOnlyWithoutQuota = ProviderSwitcherView( providers: providers, selected: .provider(.claude), includesOverview: false, width: 180, showsIcons: false, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { _ in nil }, onSelect: { _ in }) let textOnlyWithQuota = ProviderSwitcherView( providers: providers, selected: .provider(.claude), includesOverview: false, width: 180, showsIcons: false, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { _ in 50 }, onSelect: { _ in }) let withoutQuotaHeight = try #require(textOnlyWithoutQuota._test_buttonFittingSizes().first?.height) let withQuotaHeight = try #require(textOnlyWithQuota._test_buttonFittingSizes().first?.height) #expect(withQuotaHeight == withoutQuotaHeight) } @Test func `multi row switcher quota bars stay inside bounds`() { let view = ProviderSwitcherView( providers: [.codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba], selected: .provider(.codex), includesOverview: true, width: 300, showsIcons: true, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { _ in 50 }, onSelect: { _ in }) view.updateConstraintsForSubtreeIfNeeded() view.layoutSubtreeIfNeeded() for frame in view._test_buttonFrames() { #expect(frame.minY >= 0) #expect(frame.maxY <= view.bounds.maxY) } } private static func arrowKeyEvent(keyCode: UInt16) throws -> NSEvent { try #require(NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: keyCode)) } private static func commandKeyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent { try #require(NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: [.command], timestamp: 0, windowNumber: 0, context: nil, characters: characters, charactersIgnoringModifiers: characters, isARepeat: false, keyCode: keyCode)) } @Test func `multi-row switcher uses compact height and stays inside bounds`() { // 14 providers + Overview forces the four-row path and includes multi-word titles. let view = ProviderSwitcherView( providers: [ .codex, .claude, .cursor, .factory, .zai, .minimax, .alibaba, .opencodego, .grok, .groq, .gemini, .openrouter, .perplexity, .kiro, ], selected: .provider(.codex), includesOverview: true, width: 300, showsIcons: true, iconProvider: { _ in NSImage(size: NSSize(width: 16, height: 16)) }, weeklyRemainingProvider: { _ in 50 }, onSelect: { _ in }) view.updateConstraintsForSubtreeIfNeeded() view.layoutSubtreeIfNeeded() // All buttons must stay within switcher bounds (no vertical overflow). let buttonFrames = view._test_buttonFrames() let contentFrames = view._test_buttonContentFrames() let trackFrames = view._test_quotaIndicatorTrackFrames() for (frame, contentFrame) in zip(buttonFrames, contentFrames) { #expect(frame.minY >= 0) #expect(frame.maxY <= view.bounds.maxY) #expect(abs((contentFrame?.midY ?? -1) - frame.height / 2) <= 0.5) } for (buttonFrame, trackFrame) in zip(buttonFrames.dropFirst(), trackFrames) { #expect(trackFrame.minY >= buttonFrame.minY) #expect(trackFrame.maxY <= buttonFrame.maxY) } #expect(view._test_rowCount() == 4) #expect(view._test_rowHeight() == 39) #expect(view.bounds.height == 168) } }