756 lines
32 KiB
Swift
756 lines
32 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import SwiftUI
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
@MainActor
|
|
private final class RecordingMenuHighlightView: NSView, MenuCardHighlighting {
|
|
private(set) var isHighlighted = false
|
|
|
|
func setHighlighted(_ highlighted: Bool) {
|
|
self.isHighlighted = highlighted
|
|
}
|
|
}
|
|
|
|
extension StatusMenuTests {
|
|
private func makeRecyclingController(settings: SettingsStore) -> StatusItemController {
|
|
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
|
|
return StatusItemController(
|
|
store: store,
|
|
settings: settings,
|
|
account: UsageFetcher().loadAccountInfo(),
|
|
updater: DisabledUpdaterController(),
|
|
preferencesSelection: PreferencesSelection(),
|
|
statusBar: self.makeStatusBarForTesting())
|
|
}
|
|
|
|
private func cardViewIdentities(in menu: NSMenu) -> [String: ObjectIdentifier] {
|
|
var identities: [String: ObjectIdentifier] = [:]
|
|
for item in menu.items {
|
|
guard let id = item.representedObject as? String else { continue }
|
|
guard let view = item.view, view is any MenuCardMeasuring else { continue }
|
|
identities[id] = ObjectIdentifier(view)
|
|
}
|
|
return identities
|
|
}
|
|
|
|
@Test
|
|
func `menu card enabled state follows interaction affordances`() {
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
for renderingEnabled in [false, true] {
|
|
StatusItemController.menuCardRenderingEnabled = renderingEnabled
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let informational = controller.makeMenuCardItem(Text("Info"), id: "info", width: 300)
|
|
let embedded = controller.makeMenuCardItem(
|
|
Text("Embedded"),
|
|
id: "embedded",
|
|
width: 300,
|
|
containsInteractiveControls: true)
|
|
let clickable = controller.makeMenuCardItem(Text("Click"), id: "click", width: 300, onClick: {})
|
|
let submenu = controller.makeMenuCardItem(
|
|
Text("Submenu"),
|
|
id: "submenu",
|
|
width: 300,
|
|
submenu: NSMenu())
|
|
|
|
#expect(!informational.isEnabled)
|
|
#expect(embedded.isEnabled == renderingEnabled)
|
|
#expect(clickable.isEnabled)
|
|
#expect(submenu.isEnabled)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `embedded controls stay enabled without highlighting the card`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let item = controller.makeMenuCardItem(
|
|
Text("Embedded"),
|
|
id: "embedded",
|
|
width: 300,
|
|
containsInteractiveControls: true)
|
|
menu.addItem(item)
|
|
|
|
controller.menu(menu, willHighlight: item)
|
|
|
|
#expect(item.isEnabled)
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil)
|
|
guard let hosting = item.view as? MenuCardItemHostingView<MenuCardSectionContainerView<Text>>
|
|
else {
|
|
Issue.record("expected a card hosting view")
|
|
return
|
|
}
|
|
#expect(!hosting.allowsMenuHighlight)
|
|
#expect(!hosting.highlightState.isHighlighted)
|
|
}
|
|
|
|
@Test
|
|
func `merged menu width uses widest provider action set`() {
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let narrow = [
|
|
MenuDescriptor.Section(entries: [
|
|
.action("Usage Dashboard", .dashboard),
|
|
]),
|
|
]
|
|
let wide = [
|
|
MenuDescriptor.Section(entries: [
|
|
.action(String(repeating: "W", count: 60), .dashboard),
|
|
]),
|
|
]
|
|
|
|
let narrowWidth = controller.measuredMenuCardWidth(for: [narrow])
|
|
let stableWidth = controller.measuredMenuCardWidth(for: [narrow, wide])
|
|
|
|
#expect(narrowWidth == StatusItemController.menuCardBaseWidth)
|
|
#expect(stableWidth > narrowWidth)
|
|
#expect(controller.measuredMenuCardWidth(for: [wide, narrow]) == stableWidth)
|
|
}
|
|
|
|
@Test
|
|
func `menu width normalization includes usage history submenu row`() {
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let usageHistoryItem = controller.makeMenuCardItem(
|
|
Text("Plan Usage"),
|
|
id: "usageHistorySubmenu",
|
|
width: StatusItemController.menuCardBaseWidth)
|
|
menu.addItem(usageHistoryItem)
|
|
menu.addItem(NSMenuItem(
|
|
title: String(repeating: "W", count: 60),
|
|
action: nil,
|
|
keyEquivalent: ""))
|
|
|
|
let expectedWidth = controller.renderedMenuWidth(for: menu)
|
|
#expect(expectedWidth > StatusItemController.menuCardBaseWidth)
|
|
|
|
controller.refreshMenuCardHeights(in: menu)
|
|
|
|
#expect(abs((usageHistoryItem.view?.frame.width ?? 0) - expectedWidth) <= 0.5)
|
|
}
|
|
|
|
@Test
|
|
func `rendered menu width keeps tracked window width after AppKit shrink`() {
|
|
let width = StatusItemController.resolvedRenderedMenuWidth(
|
|
menuWidth: 310,
|
|
trackedWindowWidth: 356)
|
|
|
|
#expect(width == 356)
|
|
#expect(StatusItemController.resolvedRenderedMenuWidth(
|
|
menuWidth: 310,
|
|
trackedWindowWidth: nil) == 310)
|
|
}
|
|
|
|
@Test
|
|
func `data only repopulate reuses menu card hosting views`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
let registry = ProviderRegistry.shared
|
|
for provider in UsageProvider.allCases {
|
|
if let metadata = registry.metadata[provider] {
|
|
settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .codex)
|
|
}
|
|
}
|
|
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = controller.makeMenu()
|
|
controller.populateMenu(menu, provider: .codex)
|
|
let firstPass = self.cardViewIdentities(in: menu)
|
|
#expect(!firstPass.isEmpty)
|
|
|
|
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
|
|
controller.populateMenu(menu, provider: .codex)
|
|
let secondPass = self.cardViewIdentities(in: menu)
|
|
|
|
#expect(secondPass.keys.sorted() == firstPass.keys.sorted())
|
|
for (id, identity) in firstPass {
|
|
#expect(secondPass[id] == identity, "card \(id) should reuse its hosting view")
|
|
}
|
|
#expect(controller.menuCardViewRecyclePool.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `merged data tick keeps row count and card views stable`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
settings.mergedMenuLastSelectedWasOverview = false
|
|
let registry = ProviderRegistry.shared
|
|
let enabled: Set<UsageProvider> = [.codex, .claude]
|
|
for provider in UsageProvider.allCases {
|
|
if let metadata = registry.metadata[provider] {
|
|
settings.setProviderEnabled(
|
|
provider: provider,
|
|
metadata: metadata,
|
|
enabled: enabled.contains(provider))
|
|
}
|
|
}
|
|
let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false)
|
|
let controller = StatusItemController(
|
|
store: store,
|
|
settings: settings,
|
|
account: UsageFetcher().loadAccountInfo(),
|
|
updater: DisabledUpdaterController(),
|
|
preferencesSelection: PreferencesSelection(),
|
|
statusBar: self.makeStatusBarForTesting())
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
controller.selectedMenuProvider = .codex
|
|
let menu = controller.makeMenu()
|
|
controller.populateMenu(menu, provider: .codex)
|
|
let itemCountBefore = menu.items.count
|
|
let cardViewsBefore = self.cardViewIdentities(in: menu)
|
|
#expect(!cardViewsBefore.isEmpty)
|
|
|
|
controller.invalidateMenus(allowStaleContentDuringDataRefresh: true)
|
|
controller.populateMenu(menu, provider: .codex)
|
|
|
|
#expect(menu.items.count == itemCountBefore, "data-only repopulate should keep row count stable")
|
|
let cardViewsAfter = self.cardViewIdentities(in: menu)
|
|
for (id, identity) in cardViewsBefore {
|
|
#expect(cardViewsAfter[id] == identity, "card \(id) should reuse its hosting view")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `reconcile keeps matching edge rows when the middle differs`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
func plainItem(_ title: String) -> NSMenuItem {
|
|
NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
}
|
|
|
|
let menu = NSMenu()
|
|
menu.addItem(controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300))
|
|
menu.addItem(.separator())
|
|
menu.addItem(plainItem("Old Provider Action"))
|
|
menu.addItem(plainItem("Old Provider Detail"))
|
|
menu.addItem(.separator())
|
|
menu.addItem(plainItem("Settings"))
|
|
let cardItem = menu.items[0]
|
|
let cardView = cardItem.view
|
|
let settingsItem = menu.items[5]
|
|
|
|
let shapes = controller.menuContentShapes(in: menu, fromIndex: 0)
|
|
controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil)
|
|
defer { controller.clearMenuCardViewRecyclePool() }
|
|
|
|
let scratch = NSMenu()
|
|
scratch.addItem(controller.makeMenuCardItem(Text("other provider card"), id: "menuCard", width: 300))
|
|
scratch.addItem(.separator())
|
|
scratch.addItem(plainItem("New Provider Action"))
|
|
scratch.addItem(.separator())
|
|
scratch.addItem(plainItem("Settings"))
|
|
|
|
controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch)
|
|
|
|
#expect(menu.items.count == 5)
|
|
#expect(menu.items[0] === cardItem, "card row should be updated in place")
|
|
#expect(menu.items[0].view === cardView, "card hosting view should be recycled in place")
|
|
#expect(menu.items[4] === settingsItem, "shared trailing row should be updated in place")
|
|
#expect(menu.items[2].title == "New Provider Action")
|
|
}
|
|
|
|
@Test
|
|
func `cached provider content replaces native image rows and preserves switch back items`() {
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let outgoing = NSMenuItem(title: "Status Page", action: nil, keyEquivalent: "")
|
|
outgoing.image = NSImage(size: NSSize(width: 16, height: 16))
|
|
let incoming = NSMenuItem(title: "Dashboard", action: nil, keyEquivalent: "")
|
|
incoming.image = NSImage(size: NSSize(width: 16, height: 16))
|
|
let menu = NSMenu()
|
|
menu.addItem(outgoing)
|
|
|
|
let displacedOutgoing = controller.replaceMenuContentKeepingRowsVisible(
|
|
menu,
|
|
fromIndex: 0,
|
|
with: [incoming])
|
|
|
|
#expect(menu.items.first === incoming)
|
|
#expect(displacedOutgoing.first === outgoing)
|
|
|
|
let displacedIncoming = controller.replaceMenuContentKeepingRowsVisible(
|
|
menu,
|
|
fromIndex: 0,
|
|
with: displacedOutgoing)
|
|
|
|
#expect(menu.items.first === outgoing)
|
|
#expect(displacedIncoming.first === incoming)
|
|
}
|
|
|
|
@Test
|
|
func `cached provider content swap preserves both item sets for switch back`() {
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let switcher = NSMenuItem(title: "Switcher", action: nil, keyEquivalent: "")
|
|
let outgoing = [
|
|
NSMenuItem(title: "Overview Card", action: nil, keyEquivalent: ""),
|
|
NSMenuItem.separator(),
|
|
NSMenuItem(title: "Overview Action", action: nil, keyEquivalent: ""),
|
|
]
|
|
let incoming = [
|
|
NSMenuItem(title: "Codex Card", action: nil, keyEquivalent: ""),
|
|
NSMenuItem.separator(),
|
|
NSMenuItem(title: "Codex Usage", action: nil, keyEquivalent: ""),
|
|
NSMenuItem(title: "Codex Settings", action: nil, keyEquivalent: ""),
|
|
]
|
|
let menu = NSMenu()
|
|
menu.addItem(switcher)
|
|
outgoing.forEach(menu.addItem)
|
|
|
|
let displacedOutgoing = controller.replaceMenuContentKeepingRowsVisible(
|
|
menu,
|
|
fromIndex: 1,
|
|
with: incoming)
|
|
|
|
#expect(menu.items.first === switcher)
|
|
#expect(menu.items.dropFirst().map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"])
|
|
#expect(Array(menu.items[1...3]).map(ObjectIdentifier.init) == outgoing.map(ObjectIdentifier.init))
|
|
#expect(displacedOutgoing.map(ObjectIdentifier.init) == incoming.prefix(3).map(ObjectIdentifier.init))
|
|
#expect(displacedOutgoing.map(\.title) == ["Overview Card", "", "Overview Action"])
|
|
|
|
let displacedIncoming = controller.replaceMenuContentKeepingRowsVisible(
|
|
menu,
|
|
fromIndex: 1,
|
|
with: displacedOutgoing)
|
|
|
|
#expect(Array(menu.items.dropFirst()).map(ObjectIdentifier.init) == outgoing.map(ObjectIdentifier.init))
|
|
#expect(displacedIncoming.map(ObjectIdentifier.init) == incoming.map(ObjectIdentifier.init))
|
|
#expect(displacedIncoming.allSatisfy { $0.menu == nil })
|
|
#expect(displacedIncoming.map(\.title) == ["Codex Card", "", "Codex Usage", "Codex Settings"])
|
|
}
|
|
|
|
@Test
|
|
func `reconcile preserves highlight on a retained custom action row`() {
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let liveItem = NSMenuItem()
|
|
liveItem.isEnabled = true
|
|
liveItem.representedObject = "action"
|
|
liveItem.view = RecordingMenuHighlightView()
|
|
menu.addItem(liveItem)
|
|
controller.menu(menu, willHighlight: liveItem)
|
|
|
|
let replacementView = RecordingMenuHighlightView()
|
|
let replacementItem = NSMenuItem()
|
|
replacementItem.isEnabled = true
|
|
replacementItem.representedObject = "action"
|
|
replacementItem.view = replacementView
|
|
let scratch = NSMenu()
|
|
scratch.addItem(replacementItem)
|
|
|
|
let shapes = controller.menuContentShapes(in: menu, fromIndex: 0)
|
|
controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch)
|
|
|
|
#expect(menu.items[0] === liveItem)
|
|
#expect(liveItem.view === replacementView)
|
|
#expect(replacementView.isHighlighted)
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem)
|
|
}
|
|
|
|
@Test
|
|
func `reconcile restores highlight on a retained recycled card`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300, onClick: {})
|
|
menu.addItem(liveItem)
|
|
controller.menu(menu, willHighlight: liveItem)
|
|
guard let hosting = liveItem.view as? MenuCardItemHostingView<MenuCardSectionContainerView<Text>>
|
|
else {
|
|
Issue.record("expected a card hosting view")
|
|
return
|
|
}
|
|
|
|
let shapes = controller.menuContentShapes(in: menu, fromIndex: 0)
|
|
controller.harvestRecyclableMenuCardViews(
|
|
in: menu,
|
|
fromIndex: 0,
|
|
displacedSelection: nil,
|
|
preserveHighlightedItem: true)
|
|
defer { controller.clearMenuCardViewRecyclePool() }
|
|
#expect(!hosting.highlightState.isHighlighted)
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem)
|
|
|
|
let scratch = NSMenu()
|
|
scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300, onClick: {}))
|
|
controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch)
|
|
|
|
#expect(menu.items[0] === liveItem)
|
|
#expect(liveItem.view === hosting)
|
|
#expect(hosting.highlightState.isHighlighted)
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem)
|
|
}
|
|
|
|
@Test
|
|
func `reconcile clears highlight when a retained card becomes disabled`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let liveItem = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300, onClick: {})
|
|
menu.addItem(liveItem)
|
|
controller.menu(menu, willHighlight: liveItem)
|
|
guard let liveView = liveItem.view as? MenuCardItemHostingView<MenuCardSectionContainerView<Text>>
|
|
else {
|
|
Issue.record("expected a card hosting view")
|
|
return
|
|
}
|
|
#expect(liveView.highlightState.isHighlighted)
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === liveItem)
|
|
|
|
let shapes = controller.menuContentShapes(in: menu, fromIndex: 0)
|
|
let scratch = NSMenu()
|
|
scratch.addItem(controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300))
|
|
controller.reconcileMenuContent(menu, fromIndex: 0, shapes: shapes, with: scratch)
|
|
|
|
#expect(menu.items[0] === liveItem)
|
|
#expect(!liveItem.isEnabled)
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil)
|
|
guard let rebuiltView = liveItem.view as? MenuCardItemHostingView<MenuCardSectionContainerView<Text>>
|
|
else {
|
|
Issue.record("expected the rebuilt card hosting view")
|
|
return
|
|
}
|
|
#expect(!rebuiltView.highlightState.isHighlighted)
|
|
}
|
|
|
|
@Test
|
|
func `harvesting consumes only the displaced selection cache entry`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300, onClick: {})
|
|
menu.addItem(item)
|
|
|
|
let entry = CachedMergedSwitcherMenuContent(
|
|
requiredMenuContentVersion: 0,
|
|
menuWidth: 300,
|
|
codexAccountDisplay: nil,
|
|
tokenAccountDisplay: nil,
|
|
localizationSignature: "",
|
|
items: [])
|
|
controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [
|
|
.overview: entry,
|
|
.provider(.codex): entry,
|
|
]
|
|
controller.harvestRecyclableMenuCardViews(
|
|
in: menu,
|
|
fromIndex: 0,
|
|
displacedSelection: .provider(.codex))
|
|
defer { controller.clearMenuCardViewRecyclePool() }
|
|
|
|
#expect(controller.menuCardViewRecyclePool.count == 1)
|
|
#expect(item.view == nil)
|
|
let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]
|
|
#expect(remaining?[.provider(.codex)] == nil)
|
|
#expect(remaining?[.overview] != nil)
|
|
}
|
|
|
|
@Test
|
|
func `harvesting consumes displaced cache when card rendering is disabled`() {
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = false
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let entry = CachedMergedSwitcherMenuContent(
|
|
requiredMenuContentVersion: 0,
|
|
menuWidth: 300,
|
|
codexAccountDisplay: nil,
|
|
tokenAccountDisplay: nil,
|
|
localizationSignature: "",
|
|
items: [])
|
|
controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)] = [
|
|
.overview: entry,
|
|
.provider(.codex): entry,
|
|
]
|
|
|
|
controller.harvestRecyclableMenuCardViews(
|
|
in: menu,
|
|
fromIndex: 0,
|
|
displacedSelection: .provider(.codex))
|
|
|
|
let remaining = controller.mergedSwitcherContentCaches[ObjectIdentifier(menu)]
|
|
#expect(remaining?[.provider(.codex)] == nil)
|
|
#expect(remaining?[.overview] != nil)
|
|
}
|
|
|
|
@Test
|
|
func `type compatible leftover is adopted across card identifiers`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let original = controller.makeMenuCardItem(Text("codex usage"), id: "menuCard-0", width: 300)
|
|
menu.addItem(original)
|
|
let originalView = original.view
|
|
|
|
controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil)
|
|
defer { controller.clearMenuCardViewRecyclePool() }
|
|
let switched = controller.makeMenuCardItem(Text("claude usage"), id: "menuCard", width: 300)
|
|
|
|
#expect(switched.view === originalView)
|
|
#expect(controller.menuCardViewRecyclePool.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `recycled card keeps its hosting view and highlight state`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let original = controller.makeMenuCardItem(Text("before"), id: "menuCard", width: 300)
|
|
menu.addItem(original)
|
|
guard let originalView = original.view as? MenuCardItemHostingView<MenuCardSectionContainerView<Text>>
|
|
else {
|
|
Issue.record("expected a card hosting view")
|
|
return
|
|
}
|
|
|
|
controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil)
|
|
defer { controller.clearMenuCardViewRecyclePool() }
|
|
let rebuilt = controller.makeMenuCardItem(Text("after"), id: "menuCard", width: 300)
|
|
|
|
#expect(rebuilt.view === originalView)
|
|
guard let rebuiltView = rebuilt.view as? MenuCardItemHostingView<MenuCardSectionContainerView<Text>>
|
|
else {
|
|
Issue.record("expected the recycled hosting view")
|
|
return
|
|
}
|
|
#expect(rebuiltView.highlightState === originalView.highlightState)
|
|
rebuiltView.setHighlighted(true)
|
|
#expect(rebuiltView.highlightState.isHighlighted)
|
|
rebuiltView.setHighlighted(false)
|
|
}
|
|
|
|
@Test
|
|
func `recycled card clears button role when click action is removed`() {
|
|
let highlightState = MenuCardHighlightState()
|
|
let hosting = MenuCardItemHostingView(
|
|
rootView: Text("clickable"),
|
|
highlightState: highlightState,
|
|
allowsMenuHighlight: true,
|
|
onClick: {})
|
|
|
|
#expect(hosting.accessibilityRole() == .button)
|
|
|
|
hosting.prepareForReuse(
|
|
rootView: Text("informational"),
|
|
allowsMenuHighlight: false,
|
|
onClick: nil)
|
|
|
|
#expect(hosting.accessibilityRole() == .group)
|
|
}
|
|
|
|
@Test
|
|
func `harvesting a highlighted card clears its highlight and tracking entry`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let item = controller.makeMenuCardItem(Text("card"), id: "menuCard", width: 300, onClick: {})
|
|
menu.addItem(item)
|
|
controller.menu(menu, willHighlight: item)
|
|
guard let hosting = item.view as? MenuCardItemHostingView<MenuCardSectionContainerView<Text>>
|
|
else {
|
|
Issue.record("expected a card hosting view")
|
|
return
|
|
}
|
|
#expect(hosting.highlightState.isHighlighted)
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] === item)
|
|
|
|
controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil)
|
|
defer { controller.clearMenuCardViewRecyclePool() }
|
|
|
|
#expect(!hosting.highlightState.isHighlighted)
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil)
|
|
|
|
let rebuilt = controller.makeMenuCardItem(Text("rebuilt"), id: "menuCard", width: 300, onClick: {})
|
|
#expect(rebuilt.view === hosting)
|
|
#expect(!hosting.highlightState.isHighlighted)
|
|
}
|
|
|
|
@Test
|
|
func `same id with different content type builds a fresh view`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
settings.refreshFrequency = .manual
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let original = controller.makeMenuCardItem(Text("text card"), id: "menuCard", width: 300)
|
|
menu.addItem(original)
|
|
let originalView = original.view
|
|
|
|
controller.harvestRecyclableMenuCardViews(in: menu, fromIndex: 0, displacedSelection: nil)
|
|
defer { controller.clearMenuCardViewRecyclePool() }
|
|
let rebuilt = controller.makeMenuCardItem(Image(systemName: "clock"), id: "menuCard", width: 300)
|
|
|
|
#expect(rebuilt.view != nil)
|
|
#expect(rebuilt.view !== originalView)
|
|
// The incompatible pool entry is consumed rather than left behind.
|
|
#expect(controller.menuCardViewRecyclePool.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `gpu selection highlight bypasses swiftui highlight state`() {
|
|
StatusItemController.setMenuRefreshEnabledForTesting(false)
|
|
let previousRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousRendering }
|
|
|
|
let settings = self.makeSettings()
|
|
settings.statusChecksEnabled = false
|
|
let controller = self.makeRecyclingController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let menu = NSMenu()
|
|
let item = controller.makeMenuCardItem(
|
|
Text("Overview row"),
|
|
id: "overview-gpu",
|
|
width: 300,
|
|
submenu: NSMenu(),
|
|
usesGPUSelection: true,
|
|
onClick: {})
|
|
menu.addItem(item)
|
|
|
|
guard let gpuView = item.view as? GPUSelectionHostingView<Text>
|
|
else {
|
|
Issue.record("expected a GPU selection hosting view")
|
|
return
|
|
}
|
|
|
|
// The menu highlights the AppKit row, but the hosted SwiftUI highlight state must stay false
|
|
// so selection never re-invalidates the SwiftUI graph.
|
|
controller.menu(menu, willHighlight: item)
|
|
#expect(gpuView.isHighlightedForTesting)
|
|
#expect(!gpuView.swiftUIHighlightStateIsHighlightedForTesting)
|
|
|
|
controller.menu(menu, willHighlight: nil)
|
|
#expect(!gpuView.isHighlightedForTesting)
|
|
#expect(!gpuView.swiftUIHighlightStateIsHighlightedForTesting)
|
|
}
|
|
}
|