1536 lines
64 KiB
Swift
1536 lines
64 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
@MainActor
|
|
private final class ViewportRefreshGate {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class FlippedViewportDocumentView: NSView {
|
|
override var isFlipped: Bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Suite(.serialized)
|
|
struct StatusMenuViewportRestoreTests {
|
|
private func makeSettings() -> SettingsStore {
|
|
testSettingsStore(suiteName: "StatusMenuViewportRestoreTests")
|
|
}
|
|
|
|
private func makeController(settings: SettingsStore) -> StatusItemController {
|
|
let environment = Self.isolatedEnvironment()
|
|
let fetcher = UsageFetcher(environment: environment)
|
|
let store = UsageStore(
|
|
fetcher: fetcher,
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
settings: settings,
|
|
startupBehavior: .testing,
|
|
environmentBase: environment)
|
|
return StatusItemController(
|
|
store: store,
|
|
settings: settings,
|
|
account: AccountInfo(email: nil, plan: nil),
|
|
updater: DisabledUpdaterController(),
|
|
preferencesSelection: PreferencesSelection(),
|
|
statusBar: .system)
|
|
}
|
|
|
|
private static func isolatedEnvironment() -> [String: String] {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-tests", isDirectory: true)
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
return [
|
|
"HOME": root.path,
|
|
"CODEX_HOME": root.appendingPathComponent(".codex", isDirectory: true).path,
|
|
"XDG_CONFIG_HOME": root.appendingPathComponent(".config", isDirectory: true).path,
|
|
]
|
|
}
|
|
|
|
@Test
|
|
func `viewport top offset is nil when the menu content fits the clip`() {
|
|
#expect(StatusItemController.menuViewportTopOffset(
|
|
documentIsFlipped: true,
|
|
documentHeight: 500,
|
|
clipHeight: 500,
|
|
currentOffset: 0) == nil)
|
|
#expect(StatusItemController.menuViewportTopOffset(
|
|
documentIsFlipped: true,
|
|
documentHeight: 400,
|
|
clipHeight: 500,
|
|
currentOffset: 0) == nil)
|
|
#expect(StatusItemController.menuViewportTopOffset(
|
|
documentIsFlipped: true,
|
|
documentHeight: 400,
|
|
clipHeight: 0,
|
|
currentOffset: 0) == nil)
|
|
}
|
|
|
|
@Test
|
|
func `viewport top offset is nil when the viewport already shows the top`() {
|
|
#expect(StatusItemController.menuViewportTopOffset(
|
|
documentIsFlipped: true,
|
|
documentHeight: 1700,
|
|
clipHeight: 950,
|
|
currentOffset: 0) == nil)
|
|
#expect(StatusItemController.menuViewportTopOffset(
|
|
documentIsFlipped: false,
|
|
documentHeight: 1700,
|
|
clipHeight: 950,
|
|
currentOffset: 750) == nil)
|
|
}
|
|
|
|
@Test
|
|
func `viewport top offset targets the content top for a scrolled menu`() {
|
|
#expect(StatusItemController.menuViewportTopOffset(
|
|
documentIsFlipped: true,
|
|
documentHeight: 1700,
|
|
clipHeight: 950,
|
|
currentOffset: 750) == 0)
|
|
#expect(StatusItemController.menuViewportTopOffset(
|
|
documentIsFlipped: false,
|
|
documentHeight: 1700,
|
|
clipHeight: 950,
|
|
currentOffset: 0) == 750)
|
|
}
|
|
}
|
|
|
|
extension StatusMenuViewportRestoreTests {
|
|
@Test
|
|
func `settled viewport geometry distinguishes layout from movement`() {
|
|
let document = NSView()
|
|
let clipView = NSClipView()
|
|
let initial = MenuViewportGeometry(
|
|
documentID: ObjectIdentifier(document),
|
|
clipID: ObjectIdentifier(clipView),
|
|
documentSize: CGSize(width: 200, height: 600),
|
|
documentIsFlipped: false,
|
|
clipSize: CGSize(width: 200, height: 100),
|
|
clipOrigin: CGPoint(x: 0, y: 200))
|
|
|
|
#expect(StatusItemController.menuViewportGeometryTransition(
|
|
from: initial,
|
|
to: MenuViewportGeometry(
|
|
documentID: ObjectIdentifier(document),
|
|
clipID: initial.clipID,
|
|
documentSize: initial.documentSize,
|
|
documentIsFlipped: false,
|
|
clipSize: initial.clipSize,
|
|
clipOrigin: CGPoint(x: 0, y: 200.5))) == .unchanged)
|
|
#expect(StatusItemController.menuViewportGeometryTransition(
|
|
from: initial,
|
|
to: MenuViewportGeometry(
|
|
documentID: ObjectIdentifier(document),
|
|
clipID: initial.clipID,
|
|
documentSize: initial.documentSize,
|
|
documentIsFlipped: false,
|
|
clipSize: initial.clipSize,
|
|
clipOrigin: CGPoint(x: 0, y: 210))) == .movement)
|
|
#expect(StatusItemController.menuViewportGeometryTransition(
|
|
from: initial,
|
|
to: MenuViewportGeometry(
|
|
documentID: ObjectIdentifier(document),
|
|
clipID: initial.clipID,
|
|
documentSize: CGSize(width: 200, height: 500),
|
|
documentIsFlipped: false,
|
|
clipSize: initial.clipSize,
|
|
clipOrigin: .zero)) == .layout)
|
|
}
|
|
|
|
@Test
|
|
func `viewport movement tracker settles layout then accumulates fractional scrolling`() {
|
|
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
let documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500))
|
|
scrollView.documentView = documentView
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100))
|
|
let originalBoundsNotifications = scrollView.contentView.postsBoundsChangedNotifications
|
|
let originalClipFrameNotifications = scrollView.contentView.postsFrameChangedNotifications
|
|
let originalDocumentFrameNotifications = documentView.postsFrameChangedNotifications
|
|
|
|
let tracker = ManualRefreshViewportMovementTracker(scrollView: scrollView)
|
|
#expect(scrollView.contentView.postsBoundsChangedNotifications)
|
|
#expect(scrollView.contentView.postsFrameChangedNotifications)
|
|
#expect(documentView.postsFrameChangedNotifications)
|
|
|
|
// AppKit can publish the origin reset before exposing the new document height. The
|
|
// coalesced sample must see the settled geometry and classify the batch as layout.
|
|
scrollView.contentView.scroll(to: .zero)
|
|
NotificationCenter.default.post(
|
|
name: NSView.boundsDidChangeNotification,
|
|
object: scrollView.contentView)
|
|
documentView.frame.size.height = 600
|
|
tracker.settlePendingGeometryChanges()
|
|
#expect(!tracker.observedMovement)
|
|
|
|
for offset in [0.4, 0.8] {
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset))
|
|
tracker.settlePendingGeometryChanges()
|
|
#expect(!tracker.observedMovement)
|
|
}
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 1.2))
|
|
tracker.settlePendingGeometryChanges()
|
|
#expect(tracker.observedMovement)
|
|
|
|
tracker.stop()
|
|
#expect(scrollView.contentView.postsBoundsChangedNotifications == originalBoundsNotifications)
|
|
#expect(scrollView.contentView.postsFrameChangedNotifications == originalClipFrameNotifications)
|
|
#expect(documentView.postsFrameChangedNotifications == originalDocumentFrameNotifications)
|
|
}
|
|
|
|
@Test
|
|
func `refresh completion waits for settled AppKit geometry before rebasing`() {
|
|
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
let documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500))
|
|
scrollView.documentView = documentView
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100))
|
|
|
|
let tracker = ManualRefreshViewportMovementTracker(scrollView: scrollView)
|
|
defer { tracker.stop() }
|
|
|
|
// macOS 27 can publish this reset while the document still reports its old height.
|
|
scrollView.contentView.scroll(to: .zero)
|
|
NotificationCenter.default.post(
|
|
name: NSView.boundsDidChangeNotification,
|
|
object: scrollView.contentView)
|
|
var completionRan = false
|
|
tracker.afterPendingGeometrySettles {
|
|
tracker.rebaseAfterRefreshLayout()
|
|
completionRan = true
|
|
}
|
|
#expect(!completionRan)
|
|
|
|
documentView.frame.size.height = 600
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
|
|
#expect(completionRan)
|
|
#expect(!tracker.observedMovement)
|
|
}
|
|
|
|
@Test
|
|
func `viewport tracker absorbs a delayed origin correction after layout settles`() {
|
|
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
let documentView = FlippedViewportDocumentView(frame: NSRect(x: 0, y: 0, width: 200, height: 500))
|
|
scrollView.documentView = documentView
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100))
|
|
|
|
let tracker = ManualRefreshViewportMovementTracker(scrollView: scrollView)
|
|
defer { tracker.stop() }
|
|
|
|
documentView.frame.size.height = 600
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
#expect(!tracker.observedMovement)
|
|
|
|
// AppKit may correct the origin on the following pass, after geometry already settled.
|
|
scrollView.contentView.scroll(to: .zero)
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
#expect(!tracker.observedMovement)
|
|
|
|
// A further stable-geometry edge tick is user movement and remains sticky.
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 20))
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
#expect(tracker.observedMovement)
|
|
}
|
|
|
|
@Test
|
|
func `viewport observer records move away and return within one settled batch`() {
|
|
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
let documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500))
|
|
scrollView.documentView = documentView
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100))
|
|
|
|
let tracker = ManualRefreshViewportMovementTracker(scrollView: scrollView)
|
|
defer { tracker.stop() }
|
|
|
|
for offset in [120.0, 100.0] {
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset))
|
|
NotificationCenter.default.post(
|
|
name: NSView.boundsDidChangeNotification,
|
|
object: scrollView.contentView)
|
|
}
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
|
|
#expect(tracker.observedMovement)
|
|
}
|
|
|
|
@Test
|
|
func `stale completion preserves movement owned by a newer refresh`() {
|
|
let menu = NSMenu()
|
|
let key = ObjectIdentifier(menu)
|
|
let newerScrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
newerScrollView.documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500))
|
|
newerScrollView.contentView.scroll(to: NSPoint(x: 0, y: 100))
|
|
let staleScrollView = NSScrollView(frame: newerScrollView.frame)
|
|
staleScrollView.documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500))
|
|
|
|
let state = ManualRefreshViewportRestoreState()
|
|
defer { state.stopAllMovementTracking() }
|
|
state.startMovementTracking(for: key, generation: 2, scrollView: newerScrollView)
|
|
newerScrollView.contentView.scroll(to: NSPoint(x: 0, y: 120))
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
#expect(state.observedMovement(for: key, generation: 2))
|
|
|
|
var completionCount = 0
|
|
state.prepareForCompletedRefreshLayout(
|
|
for: key,
|
|
generation: 1,
|
|
scrollView: staleScrollView)
|
|
{
|
|
completionCount += 1
|
|
}
|
|
|
|
#expect(completionCount == 1)
|
|
#expect(state.observedMovement(for: key, generation: 2))
|
|
}
|
|
|
|
@Test
|
|
func `manual refresh restores originating dirty menu without rebuilding tracked parent`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
let menuID = ObjectIdentifier(menu)
|
|
|
|
var restoredMenus: [ObjectIdentifier] = []
|
|
var rebuildCount = 0
|
|
controller._test_menuViewportRestoreObserver = { restoredMenus.append(ObjectIdentifier($0)) }
|
|
controller._test_openMenuRebuildObserver = { rebuiltMenu in
|
|
if rebuiltMenu === menu {
|
|
rebuildCount += 1
|
|
}
|
|
}
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
#expect(try controller.handleMenuTrackingShortcutEvent(self.keyEvent("r", keyCode: 15), menu: menu))
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
|
|
#expect(controller.menuNeedsRefresh(menu))
|
|
#expect(controller.menuSession.isParentRebuildDeferred(menuID))
|
|
#expect(rebuildCount == 0)
|
|
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
|
|
#expect(restoredMenus == [menuID])
|
|
self.runLoop(mode: .defaultMode)
|
|
#expect(restoredMenus == [menuID])
|
|
#expect(rebuildCount == 0)
|
|
#expect(controller.menuNeedsRefresh(menu))
|
|
#expect(controller.menuSession.isParentRebuildDeferred(menuID))
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `completed manual refresh clears its request when the menu stayed clean`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
controller.markMenuFresh(menu)
|
|
|
|
var scheduledCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_manualRefreshOperation = {}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `viewport becoming attachable during refresh schedules one restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
#expect(StatusItemController.attachedMenuScrollView(in: menu) == nil)
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
_ = self.attachScrollableViewport(to: menu)
|
|
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduled.count == 1)
|
|
scheduled.removeFirst()()
|
|
#expect(restoreCount == 1)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `provider refresh restores only its originating open menu`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let claudeMenu = controller.makeMenu(for: .claude)
|
|
let codexMenu = controller.makeMenu(for: .codex)
|
|
controller.providerMenus[.claude] = claudeMenu
|
|
controller.providerMenus[.codex] = codexMenu
|
|
controller.menuWillOpen(claudeMenu)
|
|
controller.menuWillOpen(codexMenu)
|
|
defer {
|
|
controller.menuDidClose(codexMenu)
|
|
controller.menuDidClose(claudeMenu)
|
|
}
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoredMenus: [ObjectIdentifier] = []
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { restoredMenus.append(ObjectIdentifier($0)) }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: claudeMenu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.claude)])
|
|
await task.value
|
|
|
|
#expect(scheduled.count == 1)
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoredMenus == [ObjectIdentifier(claudeMenu)])
|
|
#expect(controller.menuNeedsRefresh(codexMenu))
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `closing and reopening during refresh cannot transfer restore to new tracking session`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
#expect(!controller.menuSession.pendingViewportRestores.isEmpty)
|
|
|
|
controller.menuDidClose(menu)
|
|
controller.menuWillOpen(menu)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `closing and reopening after completion invalidates scheduled restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
controller.menuDidClose(menu)
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `open hosted submenu blocks parent viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
let submenu = controller.makeHostedSubviewPlaceholderMenu(
|
|
chartID: StatusItemController.usageBreakdownChartID)
|
|
controller.openMenus[ObjectIdentifier(submenu)] = submenu
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
var rebuildCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_openMenuRebuildObserver = { rebuiltMenu in
|
|
if rebuiltMenu === menu {
|
|
rebuildCount += 1
|
|
}
|
|
}
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
|
|
#expect(scheduled.isEmpty)
|
|
#expect(restoreCount == 0)
|
|
#expect(!controller.menuSession.pendingViewportRestores.isEmpty)
|
|
#expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.count == 1)
|
|
|
|
controller.menuDidClose(submenu)
|
|
for _ in 0..<20 where scheduled.isEmpty {
|
|
await Task.yield()
|
|
}
|
|
#expect(rebuildCount == 1)
|
|
#expect(scheduled.count == 1)
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 1)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
#expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `hosted submenu opening before delivery invalidates parent viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
var rebuildCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_openMenuRebuildObserver = { rebuiltMenu in
|
|
if rebuiltMenu === menu {
|
|
rebuildCount += 1
|
|
}
|
|
}
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
let submenu = controller.makeHostedSubviewPlaceholderMenu(
|
|
chartID: StatusItemController.usageBreakdownChartID)
|
|
controller.openMenus[ObjectIdentifier(submenu)] = submenu
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(!controller.menuSession.pendingViewportRestores.isEmpty)
|
|
#expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.count == 1)
|
|
|
|
controller.menuDidClose(submenu)
|
|
for _ in 0..<20 where scheduled.isEmpty {
|
|
await Task.yield()
|
|
}
|
|
#expect(rebuildCount == 1)
|
|
#expect(scheduled.count == 1)
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 1)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
#expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `fresh parent does not defer old restore when hosted submenu opens before delivery`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
controller.rebuildOpenMenuIfStillVisible(menu, provider: .codex)
|
|
#expect(!controller.menuNeedsRefresh(menu))
|
|
controller.parentMenuRebuildPendingAfterHostedSubviewClose = true
|
|
let submenu = controller.makeHostedSubviewPlaceholderMenu(
|
|
chartID: StatusItemController.usageBreakdownChartID)
|
|
controller.openMenus[ObjectIdentifier(submenu)] = submenu
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
#expect(controller.manualRefreshViewportRestoreState.deferredUntilRebuild.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `native highlight blocks parent viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
let settingsItem = try #require(menu.items.first { $0.title == "Settings..." })
|
|
controller.menu(menu, willHighlight: settingsItem)
|
|
|
|
var scheduledCount = 0
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `native highlight before delivery invalidates parent viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
let settingsItem = try #require(menu.items.first { $0.title == "Settings..." })
|
|
controller.menu(menu, willHighlight: settingsItem)
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `custom highlight blocks parent viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
let overviewItem = NSMenuItem()
|
|
overviewItem.view = NSView()
|
|
overviewItem.isEnabled = true
|
|
menu.addItem(overviewItem)
|
|
controller.menu(menu, willHighlight: overviewItem)
|
|
|
|
var scheduledCount = 0
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.menuSession.invalidate(allowsStaleContent: false, requiresRebuild: true)
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `custom highlight before delivery invalidates parent viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
let overviewItem = NSMenuItem()
|
|
overviewItem.view = NSView()
|
|
overviewItem.isEnabled = true
|
|
menu.addItem(overviewItem)
|
|
controller.menu(menu, willHighlight: overviewItem)
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `refresh row highlight clears while its action is in flight`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
let refreshItem = try #require(menu.items.first(where: controller.isPersistentRefreshItem))
|
|
let refreshView = try #require(refreshItem.view as? PersistentRefreshMenuView)
|
|
controller.menu(menu, willHighlight: refreshItem)
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
let gate = ViewportRefreshGate()
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
|
|
#expect(refreshView.accessibilityPerformPress())
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
#expect(controller.highlightedMenuItems[ObjectIdentifier(menu)] == nil)
|
|
#expect(!refreshItem.isEnabled)
|
|
|
|
gate.resume()
|
|
await task.value
|
|
#expect(refreshItem.isEnabled)
|
|
#expect(scheduled.count == 1)
|
|
|
|
scheduled.removeFirst()()
|
|
#expect(restoreCount == 1)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `clip movement during refresh invalidates parent viewport restore without a wheel event`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
let scrollView = self.attachScrollableViewport(to: menu)
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 120))
|
|
gate.resume()
|
|
await task.value
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `scroll during refresh invalidates parent viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
let initialGeneration = try #require(controller.menuSession
|
|
.menuInteractionGeneration(for: ObjectIdentifier(menu)))
|
|
|
|
let scroll = try self.scrollEvent()
|
|
#expect(!controller.handleMenuTrackingShortcutEvent(scroll, menu: menu))
|
|
#expect(controller.menuSession.menuInteractionGeneration(for: ObjectIdentifier(menu)) == initialGeneration + 1)
|
|
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `clip movement before delivery invalidates parent viewport restore without a wheel event`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
let scrollView = self.attachScrollableViewport(to: menu)
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 120))
|
|
scheduled.removeFirst()()
|
|
self.runLoop(mode: CFRunLoopMode(RunLoop.Mode.eventTracking.rawValue as CFString))
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `scroll before delivery invalidates parent viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
let scroll = try self.scrollEvent()
|
|
#expect(!controller.handleMenuTrackingShortcutEvent(scroll, menu: menu))
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `non-manual invalidation never schedules a viewport restore`() {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
|
|
var scheduledCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
|
|
#expect(controller.menuNeedsRefresh(menu))
|
|
#expect(scheduledCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `closed origin cannot transfer restore to another open menu before task starts`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let claudeMenu = controller.makeMenu(for: .claude)
|
|
let codexMenu = controller.makeMenu(for: .codex)
|
|
controller.providerMenus[.claude] = claudeMenu
|
|
controller.providerMenus[.codex] = codexMenu
|
|
controller.menuWillOpen(claudeMenu)
|
|
|
|
var scheduledCount = 0
|
|
var restoreCount = 0
|
|
let gate = ViewportRefreshGate()
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
|
|
controller.performPersistentRefreshAction(in: ObjectIdentifier(claudeMenu))
|
|
controller.menuDidClose(claudeMenu)
|
|
controller.menuWillOpen(codexMenu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.claude)])
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `queued refresh cannot arm restore for a reopened persistent menu`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = try #require(controller.makeMenu(for: .codex) as? StatusItemMenu)
|
|
controller.providerMenus[.codex] = menu
|
|
controller.menuWillOpen(menu)
|
|
let closedSession = try #require(menu.menuInteractionGeneration)
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
|
|
menu.requestPersistentRefreshAction()
|
|
controller.menuDidClose(menu)
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
let reopenedSession = try #require(menu.menuInteractionGeneration)
|
|
#expect(reopenedSession != closedSession)
|
|
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
}
|
|
|
|
extension StatusMenuViewportRestoreTests {
|
|
@Test
|
|
func `open non-hosted child menu blocks global viewport restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let rootMenu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(rootMenu)
|
|
let submenu = NSMenu()
|
|
let submenuItem = NSMenuItem(title: "Submenu", action: nil, keyEquivalent: "")
|
|
submenuItem.submenu = submenu
|
|
rootMenu.addItem(submenuItem)
|
|
controller.menuWillOpen(submenu)
|
|
defer {
|
|
controller.menuDidClose(submenu)
|
|
controller.menuDidClose(rootMenu)
|
|
}
|
|
|
|
var scheduledCount = 0
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshNow()
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.global] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.global])
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `opening and closing non-hosted child during refresh invalidates parent restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let rootMenu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(rootMenu)
|
|
defer { controller.menuDidClose(rootMenu) }
|
|
let rootID = ObjectIdentifier(rootMenu)
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: rootMenu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
let refreshInteraction = try #require(controller.menuSession.menuInteractionGeneration(for: rootID))
|
|
|
|
let submenu = NSMenu()
|
|
let submenuItem = NSMenuItem(title: "Submenu", action: nil, keyEquivalent: "")
|
|
submenuItem.submenu = submenu
|
|
rootMenu.addItem(submenuItem)
|
|
controller.menuWillOpen(submenu)
|
|
#expect(controller.menuSession.menuInteractionGeneration(for: rootID) != refreshInteraction)
|
|
controller.menuDidClose(submenu)
|
|
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `non-hosted child opening before delivery invalidates parent restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let rootMenu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(rootMenu)
|
|
defer { controller.menuDidClose(rootMenu) }
|
|
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: rootMenu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
let submenu = NSMenu()
|
|
let submenuItem = NSMenuItem(title: "Submenu", action: nil, keyEquivalent: "")
|
|
submenuItem.submenu = submenu
|
|
rootMenu.addItem(submenuItem)
|
|
controller.menuWillOpen(submenu)
|
|
defer { controller.menuDidClose(submenu) }
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `merged selection change discards originating refresh restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
self.enableOnly([.claude, .codex], settings: settings)
|
|
settings.selectedMenuProvider = .claude
|
|
settings.mergedMenuLastSelectedWasOverview = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu()
|
|
controller.mergedMenu = menu
|
|
controller.menuWillOpen(menu)
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.claude)])
|
|
|
|
settings.selectedMenuProvider = .codex
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `merged selection ABA discards originating refresh restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
self.enableOnly([.claude, .codex], settings: settings)
|
|
settings.mergedOverviewSelectedProviders = []
|
|
settings.selectedMenuProvider = .claude
|
|
settings.mergedMenuLastSelectedWasOverview = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max
|
|
let menu = controller.makeMenu()
|
|
controller.mergedMenu = menu
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduled: [@MainActor () -> Void] = []
|
|
var restoreCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { scheduled.append($0) }
|
|
controller._test_menuViewportRestoreObserver = { _ in restoreCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.claude)])
|
|
|
|
gate.resume()
|
|
await task.value
|
|
#expect(scheduled.count == 1)
|
|
|
|
let initialGeneration = try #require(controller.menuSession
|
|
.menuInteractionGeneration(for: ObjectIdentifier(menu)))
|
|
controller.selectOverviewProvider(.codex, menu: menu)
|
|
controller.selectOverviewProvider(.claude, menu: menu)
|
|
#expect(settings.selectedMenuProvider == .claude)
|
|
#expect(controller.menuSession.menuInteractionGeneration(for: ObjectIdentifier(menu)) == initialGeneration + 2)
|
|
|
|
scheduled.removeFirst()()
|
|
|
|
#expect(restoreCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `queued refresh captures menu interaction before its task starts`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = true
|
|
self.enableOnly([.claude, .codex], settings: settings)
|
|
settings.mergedOverviewSelectedProviders = []
|
|
settings.selectedMenuProvider = .claude
|
|
settings.mergedMenuLastSelectedWasOverview = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max
|
|
let menu = try #require(controller.makeMenu() as? StatusItemMenu)
|
|
controller.mergedMenu = menu
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
|
|
menu.requestPersistentRefreshAction()
|
|
let actionGeneration = try #require(controller.menuSession
|
|
.menuInteractionGeneration(for: ObjectIdentifier(menu)))
|
|
controller.selectOverviewProvider(.codex, menu: menu)
|
|
controller.selectOverviewProvider(.claude, menu: menu)
|
|
#expect(controller.menuSession.menuInteractionGeneration(for: ObjectIdentifier(menu)) == actionGeneration + 2)
|
|
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.claude)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.claude)])
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `account selection ABA discards originating refresh restore`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
settings.multiAccountMenuLayout = .segmented
|
|
settings.statusChecksEnabled = false
|
|
self.enableOnly([.copilot], settings: settings)
|
|
settings.addTokenAccount(provider: .copilot, label: "Primary", token: "a")
|
|
settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "b")
|
|
settings.setActiveTokenAccountIndex(0, for: .copilot)
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
controller._test_providerSwitcherMenuRebuildDebounceNanoseconds = UInt64.max
|
|
let menu = controller.makeMenu(for: .copilot)
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
let switcher = try #require(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first)
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_manualRefreshOperation = {
|
|
await gate.wait()
|
|
controller.refreshOpenMenusAfterExplicitStoreAction()
|
|
}
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.copilot)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.copilot)])
|
|
let initialGeneration = try #require(controller.menuSession
|
|
.menuInteractionGeneration(for: ObjectIdentifier(menu)))
|
|
|
|
let secondaryRefresh = try #require(switcher._test_select(index: 1))
|
|
secondaryRefresh.cancel()
|
|
let primaryRefresh = try #require(switcher._test_select(index: 0))
|
|
primaryRefresh.cancel()
|
|
#expect(settings.tokenAccountsData(for: .copilot)?.clampedActiveIndex() == 0)
|
|
#expect(controller.menuSession.menuInteractionGeneration(for: ObjectIdentifier(menu)) == initialGeneration + 2)
|
|
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `cancelled manual refresh clears restore request without scheduling`() async throws {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
controller.menuRefreshEnabledOverrideForTesting = true
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
|
|
let gate = ViewportRefreshGate()
|
|
var scheduledCount = 0
|
|
controller._test_menuViewportRestoreScheduler = { _ in scheduledCount += 1 }
|
|
controller._test_manualRefreshOperation = { await gate.wait() }
|
|
controller.refreshMenuProviderNow(in: menu)
|
|
for _ in 0..<20 where controller.manualRefreshTasks[.provider(.codex)] == nil {
|
|
await Task.yield()
|
|
}
|
|
let task = try #require(controller.manualRefreshTasks[.provider(.codex)])
|
|
#expect(!controller.menuSession.pendingViewportRestores.isEmpty)
|
|
|
|
task.cancel()
|
|
gate.resume()
|
|
await task.value
|
|
|
|
#expect(scheduledCount == 0)
|
|
#expect(controller.menuSession.pendingViewportRestores.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `viewport restore is a safe no-op without an attached menu window`() {
|
|
let settings = self.makeSettings()
|
|
settings.refreshFrequency = .manual
|
|
settings.mergeIcons = false
|
|
|
|
let controller = self.makeController(settings: settings)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
let menu = controller.makeMenu(for: .codex)
|
|
controller.menuWillOpen(menu)
|
|
defer { controller.menuDidClose(menu) }
|
|
|
|
// Menu items exist but no view is hosted in a menu window, so the private
|
|
// scroll view cannot be resolved and the restore must bail out quietly.
|
|
#expect(StatusItemController.attachedMenuScrollView(in: menu) == nil)
|
|
controller.restoreMenuViewportToTop(menu)
|
|
}
|
|
|
|
private func keyEvent(_ characters: String, keyCode: UInt16) throws -> NSEvent {
|
|
try #require(NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [.command],
|
|
timestamp: 0,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: characters,
|
|
charactersIgnoringModifiers: characters,
|
|
isARepeat: false,
|
|
keyCode: keyCode))
|
|
}
|
|
|
|
private func scrollEvent() throws -> NSEvent {
|
|
let event = CGEvent(
|
|
scrollWheelEvent2Source: nil,
|
|
units: .pixel,
|
|
wheelCount: 1,
|
|
wheel1: 30,
|
|
wheel2: 0,
|
|
wheel3: 0)
|
|
return try #require(event.flatMap(NSEvent.init(cgEvent:)))
|
|
}
|
|
|
|
private func attachScrollableViewport(to menu: NSMenu) -> NSScrollView {
|
|
let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 200, height: 100))
|
|
let documentView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 500))
|
|
let hostedItemView = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 20))
|
|
let item = NSMenuItem()
|
|
item.view = hostedItemView
|
|
menu.addItem(item)
|
|
scrollView.documentView = documentView
|
|
documentView.addSubview(hostedItemView)
|
|
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 100))
|
|
#expect(StatusItemController.attachedMenuScrollView(in: menu) === scrollView)
|
|
return scrollView
|
|
}
|
|
|
|
private func enableOnly(_ providers: Set<UsageProvider>, settings: SettingsStore) {
|
|
for provider in UsageProvider.allCases {
|
|
guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue }
|
|
settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: providers.contains(provider))
|
|
}
|
|
}
|
|
|
|
private func runLoop(mode: CFRunLoopMode) {
|
|
CFRunLoopRunInMode(mode, 0.1, true)
|
|
}
|
|
}
|