918 lines
39 KiB
Swift
918 lines
39 KiB
Swift
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)
|
|
}
|
|
}
|