819 lines
35 KiB
Swift
819 lines
35 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
@MainActor
|
|
private final class SwitcherRefreshManualGate {
|
|
private var continuation: CheckedContinuation<Void, Never>?
|
|
private var isOpen = false
|
|
|
|
func wait() async {
|
|
if self.isOpen {
|
|
self.isOpen = false
|
|
return
|
|
}
|
|
await withCheckedContinuation { continuation in
|
|
self.continuation = continuation
|
|
}
|
|
}
|
|
|
|
func resume() {
|
|
if let continuation = self.continuation {
|
|
continuation.resume()
|
|
self.continuation = nil
|
|
} else {
|
|
self.isOpen = true
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite(.serialized)
|
|
struct StatusMenuSwitcherRefreshTests {
|
|
@Test
|
|
func `native switcher action preserves off tab switches after button state toggles`() {
|
|
var selections: [ProviderSwitcherSelection] = []
|
|
let switcher = ProviderSwitcherView(
|
|
providers: [.codex, .claude],
|
|
selected: .provider(.codex),
|
|
includesOverview: false,
|
|
width: 310,
|
|
showsIcons: false,
|
|
iconProvider: { _ in NSImage() },
|
|
weeklyRemainingProvider: { _ in nil },
|
|
onSelect: { selections.append($0) })
|
|
|
|
#expect(switcher._test_simulateNativeAction(buttonTag: 1, state: .on))
|
|
#expect(selections == [.provider(.claude)])
|
|
}
|
|
|
|
@Test
|
|
func `native switcher action restores active tab after native toggle`() {
|
|
var selections: [ProviderSwitcherSelection] = []
|
|
let switcher = ProviderSwitcherView(
|
|
providers: [.codex, .claude],
|
|
selected: .provider(.codex),
|
|
includesOverview: false,
|
|
width: 310,
|
|
showsIcons: false,
|
|
iconProvider: { _ in NSImage() },
|
|
weeklyRemainingProvider: { _ in nil },
|
|
onSelect: { selections.append($0) })
|
|
|
|
#expect(switcher._test_simulateNativeAction(buttonTag: 0, state: .off))
|
|
#expect(selections.isEmpty)
|
|
#expect(Self.switcherButtons(in: switcher).first { $0.tag == 0 }?.state == .on)
|
|
}
|
|
|
|
@Test
|
|
func `merged provider switch rebuilds stale width switcher rows`() async throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = false
|
|
StatusItemController.setMenuRefreshEnabledForTesting(true)
|
|
defer {
|
|
StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering
|
|
StatusItemController.resetMenuRefreshEnabledForTesting()
|
|
}
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.selectedMenuProvider = .codex
|
|
Self.enableCodexAndClaude(settings)
|
|
|
|
let activeProviders: [UsageProvider] = [.codex, .claude]
|
|
_ = settings.setMergedOverviewProviderSelection(
|
|
provider: .codex,
|
|
isSelected: false,
|
|
activeProviders: activeProviders)
|
|
_ = settings.setMergedOverviewProviderSelection(
|
|
provider: .claude,
|
|
isSelected: false,
|
|
activeProviders: activeProviders)
|
|
|
|
let fetcher = UsageFetcher()
|
|
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
|
|
store.isRefreshing = true
|
|
defer { store.isRefreshing = false }
|
|
let controller = StatusItemController(
|
|
store: store,
|
|
settings: settings,
|
|
account: fetcher.loadAccountInfo(),
|
|
updater: DisabledUpdaterController(),
|
|
preferencesSelection: PreferencesSelection(),
|
|
statusBar: .system)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.menuWillOpen(menu)
|
|
#expect(controller.openMenus[ObjectIdentifier(menu)] === menu)
|
|
|
|
let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
initialSwitcher.frame.size.width = 250
|
|
|
|
var rebuildCount = 0
|
|
controller._test_openMenuRebuildObserver = { _ in
|
|
rebuildCount += 1
|
|
}
|
|
defer { controller._test_openMenuRebuildObserver = nil }
|
|
|
|
let nextProviderButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off })
|
|
#expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: nextProviderButton.tag) == true)
|
|
|
|
for _ in 0..<100 where rebuildCount == 0 {
|
|
await Task.yield()
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
|
|
#expect(rebuildCount == 1)
|
|
let updatedSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(updatedSwitcher.frame.width == 310)
|
|
#expect(Self.switcherButtons(in: menu).first { $0.tag == nextProviderButton.tag }?.state == .on)
|
|
}
|
|
|
|
@Test
|
|
func `selected provider tab click does not rebuild open menu`() async throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = false
|
|
StatusItemController.setMenuRefreshEnabledForTesting(true)
|
|
defer {
|
|
StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering
|
|
StatusItemController.resetMenuRefreshEnabledForTesting()
|
|
}
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.selectedMenuProvider = .codex
|
|
settings.openAIWebAccessEnabled = true
|
|
Self.enableCodexAndClaude(settings)
|
|
Self.disableOverview(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() }
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.menuWillOpen(menu)
|
|
let switcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on })
|
|
|
|
var rebuildCount = 0
|
|
controller._test_openMenuRebuildObserver = { _ in
|
|
rebuildCount += 1
|
|
}
|
|
defer { controller._test_openMenuRebuildObserver = nil }
|
|
|
|
#expect(switcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag))
|
|
try? await Task.sleep(for: .milliseconds(40))
|
|
|
|
#expect(rebuildCount == 0)
|
|
#expect(Self.switcherButtons(in: menu).first { $0.tag == selectedButton.tag }?.state == .on)
|
|
}
|
|
|
|
@Test
|
|
func `merged provider switch updates live tab rows in place`() async throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = false
|
|
StatusItemController.setMenuRefreshEnabledForTesting(true)
|
|
defer {
|
|
StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering
|
|
StatusItemController.resetMenuRefreshEnabledForTesting()
|
|
}
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.selectedMenuProvider = .codex
|
|
Self.enableCodexAndClaude(settings)
|
|
Self.disableOverview(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() }
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.menuWillOpen(menu)
|
|
let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu)
|
|
#expect(menu.items.indices.contains(contentStartIndex))
|
|
let originalContentID = ObjectIdentifier(menu.items[contentStartIndex])
|
|
let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on })
|
|
let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off })
|
|
|
|
var rebuildCount = 0
|
|
controller._test_openMenuRebuildObserver = { _ in
|
|
rebuildCount += 1
|
|
}
|
|
defer { controller._test_openMenuRebuildObserver = nil }
|
|
|
|
// Provider switches now reconcile matching rows in place instead of parking and
|
|
// restoring distinct item sets per tab: the same NSMenuItem objects carry each
|
|
// tab's freshly built content, so AppKit never relayouts the open menu per insert.
|
|
let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag))
|
|
await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount })
|
|
#expect(menu.items.indices.contains(contentStartIndex))
|
|
#expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID)
|
|
|
|
let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag))
|
|
await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount })
|
|
#expect(menu.items.indices.contains(contentStartIndex))
|
|
#expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID)
|
|
|
|
let restoredSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(restoredSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag))
|
|
await Self.waitForRebuildCount(3, rebuildCount: { rebuildCount })
|
|
#expect(menu.items.indices.contains(contentStartIndex))
|
|
#expect(ObjectIdentifier(menu.items[contentStartIndex]) == originalContentID)
|
|
|
|
controller.invalidateMenus()
|
|
#expect(controller.mergedSwitcherContentCaches.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `smart provider switch resizes persistent refresh row to rendered menu width`() throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
let previousMenuRefresh = StatusItemController.menuRefreshEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
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
|
|
Self.enableCodexAndClaude(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() }
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.menuWillOpen(menu)
|
|
menu.minimumWidth = 420
|
|
|
|
let descriptor = controller.makeMenuDescriptor(provider: .claude, includeContextualActions: true)
|
|
controller.updateMenuContentPreservingSwitcher(
|
|
menu,
|
|
context: StatusItemController.MenuUpdateContext(
|
|
provider: .claude,
|
|
currentProvider: .claude,
|
|
switcherSelection: .provider(.claude),
|
|
menuWidth: StatusItemController.menuCardBaseWidth,
|
|
codexAccountDisplay: nil,
|
|
tokenAccountDisplay: nil,
|
|
openAIContext: StatusItemController.OpenAIWebContext(
|
|
hasUsageBreakdown: false,
|
|
hasCreditsHistory: false,
|
|
hasCostHistory: false,
|
|
canShowBuyCredits: false,
|
|
hasOpenAIWebMenuItems: false),
|
|
descriptor: descriptor))
|
|
|
|
let expectedWidth = controller.renderedMenuWidth(for: menu)
|
|
#expect(expectedWidth == 420)
|
|
let refreshView = try #require(menu.items.first { $0.title == "Refresh" }?.view as? PersistentRefreshMenuView)
|
|
#expect(abs(refreshView.frame.width - expectedWidth) <= 0.5)
|
|
}
|
|
|
|
@Test
|
|
func `manual refresh keeps codex quota visible after switching away and back`() async throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = false
|
|
StatusItemController.setMenuRefreshEnabledForTesting(true)
|
|
defer {
|
|
StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering
|
|
StatusItemController.resetMenuRefreshEnabledForTesting()
|
|
}
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.selectedMenuProvider = .codex
|
|
Self.enableCodexAndClaude(settings)
|
|
Self.disableOverview(settings)
|
|
|
|
let fetcher = UsageFetcher()
|
|
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
|
|
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
|
store._setSnapshotForTesting(Self.quotaSnapshot(usedPercent: 21, updatedAt: now), provider: .codex)
|
|
store._setSnapshotForTesting(Self.quotaSnapshot(usedPercent: 44, updatedAt: now), provider: .claude)
|
|
let event = CreditEvent(date: now, service: "CLI", creditsUsed: 1)
|
|
let breakdown = OpenAIDashboardSnapshot.makeDailyBreakdown(from: [event], maxDays: 30)
|
|
store.openAIDashboard = OpenAIDashboardSnapshot(
|
|
signedInEmail: "test@example.com",
|
|
codeReviewRemainingPercent: nil,
|
|
creditEvents: [event],
|
|
dailyBreakdown: breakdown,
|
|
usageBreakdown: breakdown,
|
|
creditsPurchaseURL: nil,
|
|
updatedAt: now)
|
|
store.openAIDashboardAttachmentAuthorized = true
|
|
store.openAIDashboardRequiresLogin = false
|
|
let controller = StatusItemController(
|
|
store: store,
|
|
settings: settings,
|
|
account: AccountInfo(email: "test@example.com", plan: "pro"),
|
|
updater: DisabledUpdaterController(),
|
|
preferencesSelection: PreferencesSelection(),
|
|
statusBar: .system)
|
|
defer {
|
|
controller.manualRefreshTasks.values.forEach { $0.cancel() }
|
|
controller.releaseStatusItemsForTesting()
|
|
}
|
|
|
|
let gate = SwitcherRefreshManualGate()
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
}
|
|
defer { controller._test_manualRefreshOperation = nil }
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.menuWillOpen(menu)
|
|
let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on })
|
|
let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off })
|
|
|
|
controller.refreshNow()
|
|
#expect(controller.menuCardRefreshMonitor.isManualRefreshInFlight)
|
|
store.refreshingProviders.insert(.codex)
|
|
|
|
store._setSnapshotForTesting(
|
|
UsageSnapshot(primary: nil, secondary: nil, updatedAt: now.addingTimeInterval(1)),
|
|
provider: .codex)
|
|
|
|
var rebuildCount = 0
|
|
controller._test_openMenuRebuildObserver = { _ in
|
|
rebuildCount += 1
|
|
}
|
|
defer { controller._test_openMenuRebuildObserver = nil }
|
|
|
|
let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag))
|
|
await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount })
|
|
|
|
let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag))
|
|
await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount })
|
|
#expect(settings.selectedMenuProvider == .codex)
|
|
|
|
let usageItem = menu.items.first { ($0.representedObject as? String) == "menuCardUsage" }
|
|
#expect(usageItem != nil)
|
|
#expect(menu.items.contains { ($0.representedObject as? String) == "menuCardHeader" } == false)
|
|
|
|
let emptyFallback = try #require(controller.menuCardModel(for: .codex))
|
|
let inFlight = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback)
|
|
let subtitle = controller.menuCardRefreshMonitor.subtitle(
|
|
for: .codex,
|
|
fallback: MenuCardLiveSubtitle(text: emptyFallback.subtitleText, style: emptyFallback.subtitleStyle))
|
|
|
|
#expect(emptyFallback.metrics.isEmpty)
|
|
#expect(subtitle.text == "Refreshing…")
|
|
#expect(inFlight.metrics.first?.percentLabel == "79% left")
|
|
|
|
gate.resume()
|
|
store.refreshingProviders.remove(.codex)
|
|
await controller.manualRefreshTasks[.global]?.value
|
|
#expect(!controller.menuCardRefreshMonitor.isManualRefreshInFlight)
|
|
let completed = controller.menuCardRefreshMonitor.model(for: .codex, fallback: emptyFallback)
|
|
#expect(completed.metrics.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `completed refresh re-enables cached item when switching back`() async throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = false
|
|
StatusItemController.setMenuRefreshEnabledForTesting(true)
|
|
defer {
|
|
StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering
|
|
StatusItemController.resetMenuRefreshEnabledForTesting()
|
|
}
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.selectedMenuProvider = .codex
|
|
Self.enableCodexAndClaude(settings)
|
|
Self.disableOverview(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.manualRefreshTasks.values.forEach { $0.cancel() }
|
|
controller.releaseStatusItemsForTesting()
|
|
}
|
|
|
|
let gate = SwitcherRefreshManualGate()
|
|
controller._test_manualRefreshOperation = { await gate.wait() }
|
|
defer { controller._test_manualRefreshOperation = nil }
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.menuWillOpen(menu)
|
|
let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on })
|
|
let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off })
|
|
let initialRefreshItem = try #require(menu.items.first { $0.title == "Refresh" })
|
|
|
|
controller.refreshNow()
|
|
let refreshTask = try #require(controller.manualRefreshTasks[.global])
|
|
#expect(!initialRefreshItem.isEnabled)
|
|
|
|
var rebuildCount = 0
|
|
controller._test_openMenuRebuildObserver = { _ in rebuildCount += 1 }
|
|
defer { controller._test_openMenuRebuildObserver = nil }
|
|
|
|
let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag))
|
|
await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount })
|
|
|
|
gate.resume()
|
|
await refreshTask.value
|
|
#expect(controller.manualRefreshTasks[.global] == nil)
|
|
|
|
let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag))
|
|
await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount })
|
|
|
|
let restoredRefreshItem = try #require(menu.items.first { $0.title == "Refresh" })
|
|
#expect(restoredRefreshItem.isEnabled)
|
|
}
|
|
|
|
@Test
|
|
func `full cached reattachment resynchronizes detached refresh item`() throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = false
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering }
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.selectedMenuProvider = .codex
|
|
Self.enableCodexAndClaude(settings)
|
|
Self.disableOverview(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.manualRefreshTasks.values.forEach { $0.cancel() }
|
|
controller.releaseStatusItemsForTesting()
|
|
}
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.menuWillOpen(menu)
|
|
let cache = try #require(
|
|
controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]?[.provider(.codex)])
|
|
let refreshItem = try #require(cache.items.first { $0.title == "Refresh" })
|
|
|
|
controller.manualRefreshTasks[.global] = Task {}
|
|
controller.updatePersistentRefreshItemsEnabled()
|
|
#expect(!refreshItem.isEnabled)
|
|
|
|
menu.removeAllItems()
|
|
#expect(refreshItem.menu == nil)
|
|
controller.manualRefreshTasks[.global] = nil
|
|
controller.updatePersistentRefreshItemsEnabled()
|
|
#expect(!refreshItem.isEnabled)
|
|
|
|
#expect(controller.addCachedMergedSwitcherContent(
|
|
for: .provider(.codex),
|
|
to: menu,
|
|
menuWidth: cache.menuWidth,
|
|
codexAccountDisplay: cache.codexAccountDisplay,
|
|
tokenAccountDisplay: cache.tokenAccountDisplay))
|
|
#expect(refreshItem.menu === menu)
|
|
#expect(refreshItem.isEnabled)
|
|
}
|
|
|
|
@Test
|
|
func `a provider manual refresh only greys its own tab`() {
|
|
let settings = Self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.selectedMenuProvider = .codex
|
|
Self.enableCodexAndClaude(settings)
|
|
Self.disableOverview(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.manualRefreshTasks.values.forEach { $0.cancel() }
|
|
controller.manualRefreshTasks.removeAll()
|
|
controller.releaseStatusItemsForTesting()
|
|
}
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.mergedMenu = menu
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
|
|
// A manual refresh of Claude must leave the Codex tab's Refresh row enabled.
|
|
controller.manualRefreshTasks[.provider(.claude)] = Task {}
|
|
#expect(!controller.isRefreshActionInFlight(for: menu))
|
|
|
|
// Switching to the Claude tab reflects Claude's own in-flight refresh.
|
|
settings.selectedMenuProvider = .claude
|
|
#expect(controller.isRefreshActionInFlight(for: menu))
|
|
|
|
// An all-providers refresh busies every tab regardless of the selected provider.
|
|
settings.selectedMenuProvider = .codex
|
|
controller.manualRefreshTasks[.provider(.claude)] = nil
|
|
controller.manualRefreshTasks[.global] = Task {}
|
|
#expect(controller.isRefreshActionInFlight(for: menu))
|
|
}
|
|
|
|
@Test
|
|
func `native image menu rows are replaced during reconciliation`() {
|
|
let settings = Self.makeSettings()
|
|
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 menu = NSMenu()
|
|
let liveItem = Self.nativeImageItem(title: "Status Page")
|
|
menu.addItem(liveItem)
|
|
let shapes = controller.menuContentShapes(in: menu, fromIndex: 0)
|
|
|
|
let scratch = NSMenu()
|
|
let freshItem = Self.nativeImageItem(title: "Status Page")
|
|
scratch.addItem(freshItem)
|
|
|
|
controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch)
|
|
|
|
#expect(menu.items.count == 1)
|
|
#expect(ObjectIdentifier(menu.items[0]) == ObjectIdentifier(freshItem))
|
|
#expect(ObjectIdentifier(menu.items[0]) != ObjectIdentifier(liveItem))
|
|
}
|
|
|
|
@Test
|
|
func `native image submenu rows reconcile in place`() {
|
|
let settings = Self.makeSettings()
|
|
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 menu = NSMenu()
|
|
let liveItem = Self.nativeImageItem(title: "System Account")
|
|
liveItem.submenu = NSMenu(title: "System Account")
|
|
menu.addItem(liveItem)
|
|
let shapes = controller.menuContentShapes(in: menu, fromIndex: 0)
|
|
|
|
let scratch = NSMenu()
|
|
let freshItem = Self.nativeImageItem(title: "System Account")
|
|
freshItem.submenu = NSMenu(title: "System Account")
|
|
scratch.addItem(freshItem)
|
|
|
|
controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch)
|
|
|
|
#expect(menu.items.count == 1)
|
|
#expect(ObjectIdentifier(menu.items[0]) == ObjectIdentifier(liveItem))
|
|
#expect(ObjectIdentifier(menu.items[0]) != ObjectIdentifier(freshItem))
|
|
}
|
|
|
|
@Test
|
|
func `provider switch does not cache stale rows after required invalidation`() async throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = false
|
|
StatusItemController.setMenuRefreshEnabledForTesting(true)
|
|
defer {
|
|
StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering
|
|
StatusItemController.resetMenuRefreshEnabledForTesting()
|
|
}
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.selectedMenuProvider = .codex
|
|
Self.enableCodexAndClaude(settings)
|
|
Self.disableOverview(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() }
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.menuWillOpen(menu)
|
|
let contentStartIndex = controller.providerSwitcherContentStartIndex(in: menu)
|
|
#expect(menu.items.indices.contains(contentStartIndex))
|
|
let selectedButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .on })
|
|
let alternateButton = try #require(Self.switcherButtons(in: menu).first { $0.state == .off })
|
|
|
|
var rebuildCount = 0
|
|
controller._test_openMenuRebuildObserver = { _ in
|
|
rebuildCount += 1
|
|
}
|
|
defer { controller._test_openMenuRebuildObserver = nil }
|
|
|
|
controller.invalidateMenus()
|
|
#expect(controller.mergedSwitcherContentCaches.isEmpty)
|
|
let initialSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(initialSwitcher._test_simulateRuntimeClick(buttonTag: alternateButton.tag))
|
|
await Self.waitForRebuildCount(1, rebuildCount: { rebuildCount })
|
|
|
|
let alternateSwitcher = try #require(menu.items.first?.view as? ProviderSwitcherView)
|
|
#expect(alternateSwitcher._test_simulateRuntimeClick(buttonTag: selectedButton.tag))
|
|
await Self.waitForRebuildCount(2, rebuildCount: { rebuildCount })
|
|
|
|
// Rows are reconciled in place, so freshness is guaranteed by rebuilding content
|
|
// from current data rather than by minting new items: the live menu must be marked
|
|
// fresh and no cached entry may predate the required invalidation. (In-place item
|
|
// identity itself is covered deterministically in MenuCardViewRecyclingTests; here
|
|
// async gate state may legitimately route a populate through the full rebuild.)
|
|
#expect(menu.items.indices.contains(contentStartIndex))
|
|
let menuKey = ObjectIdentifier(menu)
|
|
#expect(controller.menuVersions[menuKey] == controller.menuContentVersion)
|
|
for entry in controller.mergedSwitcherContentCaches[menuKey]?.values ?? [:].values {
|
|
#expect(entry.requiredMenuContentVersion >= controller.latestRequiredMenuRebuildVersion)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `tab switch does not replace quota indicator constraints`() {
|
|
let switcher = ProviderSwitcherView(
|
|
providers: [.codex, .claude],
|
|
selected: .provider(.codex),
|
|
includesOverview: false,
|
|
width: 310,
|
|
showsIcons: false,
|
|
iconProvider: { _ in NSImage() },
|
|
weeklyRemainingProvider: { _ in 75.0 },
|
|
onSelect: { _ in })
|
|
|
|
let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers()
|
|
#expect(initialConstraints.count == 2, "both providers should have quota indicators")
|
|
|
|
switcher.updateQuotaIndicators()
|
|
|
|
let afterFirstCall = switcher._test_quotaIndicatorConstraintIdentifiers()
|
|
#expect(afterFirstCall == initialConstraints, "same ratio: constraints must not be replaced")
|
|
}
|
|
|
|
@Test
|
|
func `quota indicator constraints are replaced when ratio changes`() {
|
|
var currentRemaining = 75.0
|
|
let switcher = ProviderSwitcherView(
|
|
providers: [.codex, .claude],
|
|
selected: .provider(.codex),
|
|
includesOverview: false,
|
|
width: 310,
|
|
showsIcons: false,
|
|
iconProvider: { _ in NSImage() },
|
|
weeklyRemainingProvider: { _ in currentRemaining },
|
|
onSelect: { _ in })
|
|
|
|
let initialConstraints = switcher._test_quotaIndicatorConstraintIdentifiers()
|
|
#expect(initialConstraints.count == 2)
|
|
|
|
currentRemaining = 40.0
|
|
switcher.updateQuotaIndicators()
|
|
|
|
let afterDataChange = switcher._test_quotaIndicatorConstraintIdentifiers()
|
|
#expect(afterDataChange != initialConstraints, "changed ratio: constraints should be replaced")
|
|
}
|
|
|
|
private static func makeSettings() -> SettingsStore {
|
|
let suite = "StatusMenuSwitcherRefreshTests-\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defaults.removePersistentDomain(forName: suite)
|
|
defaults.set(true, forKey: "providerDetectionCompleted")
|
|
return SettingsStore(
|
|
userDefaults: defaults,
|
|
configStore: testConfigStore(suiteName: suite),
|
|
zaiTokenStore: NoopZaiTokenStore(),
|
|
syntheticTokenStore: NoopSyntheticTokenStore())
|
|
}
|
|
|
|
private static func enableCodexAndClaude(_ settings: SettingsStore) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
private static func disableOverview(_ settings: SettingsStore) {
|
|
let activeProviders: [UsageProvider] = [.codex, .claude]
|
|
_ = settings.setMergedOverviewProviderSelection(
|
|
provider: .codex,
|
|
isSelected: false,
|
|
activeProviders: activeProviders)
|
|
_ = settings.setMergedOverviewProviderSelection(
|
|
provider: .claude,
|
|
isSelected: false,
|
|
activeProviders: activeProviders)
|
|
}
|
|
|
|
private static func quotaSnapshot(usedPercent: Double, updatedAt: Date) -> UsageSnapshot {
|
|
UsageSnapshot(
|
|
primary: RateWindow(
|
|
usedPercent: usedPercent,
|
|
windowMinutes: 300,
|
|
resetsAt: updatedAt.addingTimeInterval(3600),
|
|
resetDescription: nil),
|
|
secondary: nil,
|
|
updatedAt: updatedAt)
|
|
}
|
|
|
|
private static func waitForRebuildCount(
|
|
_ expectedCount: Int,
|
|
rebuildCount: () -> Int) async
|
|
{
|
|
for _ in 0..<100 where rebuildCount() < expectedCount {
|
|
await Task.yield()
|
|
try? await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
}
|
|
|
|
private static func switcherButtons(in menu: NSMenu) -> [NSButton] {
|
|
guard let switcherView = menu.items.first?.view as? ProviderSwitcherView else { return [] }
|
|
return self.switcherButtons(in: switcherView)
|
|
}
|
|
|
|
private static func switcherButtons(in switcherView: ProviderSwitcherView) -> [NSButton] {
|
|
switcherView.subviews
|
|
.compactMap { $0 as? NSButton }
|
|
.sorted { $0.tag < $1.tag }
|
|
}
|
|
|
|
private static func nativeImageItem(title: String) -> NSMenuItem {
|
|
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
item.image = NSImage(size: NSSize(width: 16, height: 16))
|
|
return item
|
|
}
|
|
}
|