Files
steipete--codexbar/Tests/CodexBarTests/StatusMenuSwitcherRefreshTests.swift
2026-07-13 12:22:33 +08:00

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
}
}