import AppKit import CodexBarCore import Foundation import Testing @testable import CodexBar @MainActor @Suite(.serialized) struct MenuBarCountdownRefreshTests { @Test func `countdown refresh delay follows the next displayed minute boundary`() { let now = Date(timeIntervalSince1970: 1_800_000_000) let delay = StatusItemController.menuBarCountdownRefreshDelay( resetDates: [ now.addingTimeInterval(2 * 3600 + 15 * 60 + 30), now.addingTimeInterval(45), ], now: now) #expect(abs((delay ?? 0) - 30.05) < 0.001) } @Test func `countdown refresh ignores elapsed reset dates`() { let now = Date(timeIntervalSince1970: 1_800_000_000) let delay = StatusItemController.menuBarCountdownRefreshDelay( resetDates: [now.addingTimeInterval(-1)], now: now) #expect(delay == nil) } @Test func `absolute refresh observes local midnight before the reset`() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) let now = try #require(calendar.date(from: DateComponents( year: 2026, month: 7, day: 10, hour: 23, minute: 59))) let reset = try #require(calendar.date(byAdding: .hour, value: 2, to: now)) let delay = StatusItemController.menuBarAbsoluteRefreshDelay( resetDates: [reset], now: now, calendar: calendar) #expect(abs((delay ?? 0) - 60.05) < 0.001) } @Test func `absolute refresh observes midnight after a skipped day start`() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "America/Santiago")) let now = try #require(calendar.date(from: DateComponents( year: 2024, month: 9, day: 8, hour: 23))) let reset = try #require(calendar.date(byAdding: .hour, value: 3, to: now)) let delay = StatusItemController.menuBarAbsoluteRefreshDelay( resetDates: [reset], now: now, calendar: calendar) #expect(abs((delay ?? 0) - 3600.05) < 0.001) } @Test func `status item schedules countdown and exhausted lane refreshes`() { let settings = SettingsStore( configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-scheduling"), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.menuBarShowsBrandIconWithPercent = true settings.menuBarDisplayMode = .resetTime settings.resetTimesShowAbsolute = false if let metadata = ProviderRegistry.shared.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) } let fetcher = UsageFetcher() let store = UsageStore( fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 42, windowMinutes: 300, resetsAt: Date().addingTimeInterval(90), resetDescription: nil), secondary: nil, updatedAt: Date()), provider: .codex) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } #expect(controller._test_isMenuBarCountdownRefreshScheduled()) settings.resetTimesShowAbsolute = true controller.updateIcons() #expect(controller._test_isMenuBarCountdownRefreshScheduled()) settings.menuBarShowsBrandIconWithPercent = false controller.updateIcons() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) settings.menuBarShowsBrandIconWithPercent = true let now = Date() store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 1, windowMinutes: 300, resetsAt: now.addingTimeInterval(60), resetDescription: nil), secondary: RateWindow( usedPercent: 100, windowMinutes: 10080, resetsAt: now.addingTimeInterval(90), resetDescription: nil), updatedAt: now), provider: .codex) controller.updateIcons() #expect(controller._test_isMenuBarCountdownRefreshScheduled()) store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 1, windowMinutes: 300, resetsAt: now.addingTimeInterval(60), resetDescription: nil), secondary: RateWindow( usedPercent: 100, windowMinutes: 10080, resetsAt: now.addingTimeInterval(-1), resetDescription: nil), updatedAt: now), provider: .codex) controller.updateIcons() // The elapsed weekly cap falls out of the projection; absolute reset-time mode now observes the // still-future session reset instead of leaving its label stale. #expect(controller._test_isMenuBarCountdownRefreshScheduled()) store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 100, windowMinutes: 300, resetsAt: now.addingTimeInterval(90), resetDescription: nil), secondary: RateWindow( usedPercent: 40, windowMinutes: 10080, resetsAt: now.addingTimeInterval(3600), resetDescription: nil), updatedAt: now), provider: .codex) controller.updateIcons() #expect(controller._test_isMenuBarCountdownRefreshScheduled()) settings.resetTimesShowAbsolute = false store._setSnapshotForTesting(nil, provider: .codex) controller.updateIcons() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 42, windowMinutes: 300, resetsAt: Date().addingTimeInterval(90), resetDescription: nil), secondary: nil, updatedAt: Date()), provider: .codex) controller.updateIcons() #expect(controller._test_isMenuBarCountdownRefreshScheduled()) controller.prepareForAppShutdown() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) } @Test func `absolute clock smart mode schedules the exhausted reset boundary`() { // Isolated defaults: this test enables the smart option, which must not leak into `.standard` // and flip other suites' exhausted-lane expectations. let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-absolute-smart") settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.menuBarShowsBrandIconWithPercent = true settings.menuBarDisplayMode = .percent settings.menuBarShowsResetTimeWhenExhausted = true // Absolute clock style: the per-minute countdown scheduler is skipped, but a smart-exhausted // lane still needs a boundary refresh so it falls back to the percentage once the reset passes. settings.resetTimesShowAbsolute = true if let metadata = ProviderRegistry.shared.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) } let fetcher = UsageFetcher() let store = UsageStore( fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let now = Date() // Exhausted lane with a future reset → schedule a boundary refresh even in absolute mode. store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 100, windowMinutes: 300, resetsAt: now.addingTimeInterval(90), resetDescription: nil), secondary: nil, updatedAt: now), provider: .codex) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } #expect(controller._test_isMenuBarCountdownRefreshScheduled()) // Elapsed reset → nothing to schedule (the lane already falls back to the percentage). store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 100, windowMinutes: 300, resetsAt: now.addingTimeInterval(-1), resetDescription: nil), secondary: nil, updatedAt: now), provider: .codex) controller.updateIcons() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) // Healthy quota → smart replacement inactive, so no boundary refresh. store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 40, windowMinutes: 300, resetsAt: now.addingTimeInterval(90), resetDescription: nil), secondary: nil, updatedAt: now), provider: .codex) controller.updateIcons() #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) } @Test(arguments: [MenuBarDisplayMode.percent, .pace, .both]) func `combined metric schedules every displayed exhausted reset lane`(mode: MenuBarDisplayMode) { for usesAbsoluteClock in [false, true] { // Isolated defaults: enabling the smart option must not leak into `.standard`. let settings = testSettingsStore( suiteName: "MenuBarCountdownRefreshTests-combined-lanes-\(mode.rawValue)-\(usesAbsoluteClock)") settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.menuBarShowsBrandIconWithPercent = true settings.menuBarDisplayMode = mode settings.menuBarShowsResetTimeWhenExhausted = true settings.resetTimesShowAbsolute = usesAbsoluteClock settings.mergeIcons = false settings.selectedMenuProvider = .claude settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) if let metadata = ProviderRegistry.shared.metadata[.claude] { settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) } let fetcher = UsageFetcher() let store = UsageStore( fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let now = Date() // Both combined lanes exhausted: the session (5h) reset has already elapsed, the weekly (7d) // reset is still ahead. The scheduler must consider the weekly lane, not just the icon-metric // lane, so the still-displayed weekly countdown or absolute clock reaches its reset boundary. let sessionReset = now.addingTimeInterval(-60) let weeklyReset = now.addingTimeInterval(3600) store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 100, windowMinutes: 300, resetsAt: sessionReset, resetDescription: nil), secondary: RateWindow( usedPercent: 100, windowMinutes: 10080, resetsAt: weeklyReset, resetDescription: nil), updatedAt: now), provider: .claude) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) controller.updateIcons() let dates = controller.menuBarDisplayedResetDates(for: .claude, now: now) switch mode { case .percent: // Percent displays both lane values independently. #expect(dates == [sessionReset, weeklyReset]) case .pace, .both: // Pace/both surface the exhausted weekly lane, not the session lane that wins the 100/100 // icon-metric tie. #expect(dates == [weeklyReset]) case .resetTime: Issue.record("reset-time mode is not an argument for this smart-reset test") } // The future weekly boundary stays scheduled for countdown and absolute-clock styles even // though the session lane elapsed. #expect(controller._test_isMenuBarCountdownRefreshScheduled()) controller.releaseStatusItemsForTesting() } } @Test func `combined metric falls through to a nonstandard exhausted fallback lane`() { let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-combined-fallback") settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.menuBarShowsBrandIconWithPercent = true settings.menuBarDisplayMode = .percent settings.menuBarShowsResetTimeWhenExhausted = true settings.resetTimesShowAbsolute = false settings.mergeIcons = false settings.selectedMenuProvider = .claude settings.setMenuBarMetricPreference(.primaryAndSecondary, for: .claude) if let metadata = ProviderRegistry.shared.metadata[.claude] { settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true) } let fetcher = UsageFetcher() let store = UsageStore( fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let now = Date() let reset = now.addingTimeInterval(3600) let snapshot = UsageSnapshot( primary: RateWindow( usedPercent: 100, windowMinutes: 60, resetsAt: reset, resetDescription: nil), secondary: nil, updatedAt: now) store._setSnapshotForTesting(snapshot, provider: .claude) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } #expect(controller.menuBarDisplayText(for: .claude, snapshot: snapshot, now: now) == "↻ in 1h") #expect(controller.menuBarDisplayedResetDates(for: .claude, now: now) == [reset]) #expect(controller._test_isMenuBarCountdownRefreshScheduled()) } @Test func `time environment change reschedules an absolute reset label`() { let settings = testSettingsStore(suiteName: "MenuBarCountdownRefreshTests-time-environment") settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.menuBarShowsBrandIconWithPercent = true settings.menuBarDisplayMode = .resetTime settings.resetTimesShowAbsolute = true if let metadata = ProviderRegistry.shared.metadata[.codex] { settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true) } 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() } #expect(!controller._test_isMenuBarCountdownRefreshScheduled()) let now = Date() store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 42, windowMinutes: 300, resetsAt: now.addingTimeInterval(3600), resetDescription: nil), secondary: nil, updatedAt: now), provider: .codex) controller.handleMenuBarTimeEnvironmentChange() #expect(controller._test_isMenuBarCountdownRefreshScheduled()) } @Test func `merged highest usage observes reset for noncurrent Codex candidate`() throws { let settings = SettingsStore( configStore: testConfigStore(suiteName: "MenuBarCountdownRefreshTests-merged-highest"), zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore()) settings.statusChecksEnabled = false settings.refreshFrequency = .manual settings.mergeIcons = true settings.menuBarShowsHighestUsage = true settings.menuBarShowsBrandIconWithPercent = true settings.menuBarDisplayMode = .percent settings.resetTimesShowAbsolute = true let registry = ProviderRegistry.shared try settings.setProviderEnabled( provider: .codex, metadata: #require(registry.metadata[.codex]), enabled: true) try settings.setProviderEnabled( provider: .claude, metadata: #require(registry.metadata[.claude]), enabled: true) let fetcher = UsageFetcher() let store = UsageStore( fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) let now = Date() store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 1, windowMinutes: 300, resetsAt: now.addingTimeInterval(60), resetDescription: nil), secondary: RateWindow( usedPercent: 100, windowMinutes: 10080, resetsAt: now.addingTimeInterval(90), resetDescription: nil), updatedAt: now), provider: .codex) store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow(usedPercent: 80, windowMinutes: 300, resetsAt: nil, resetDescription: nil), secondary: nil, updatedAt: now), provider: .claude) let controller = StatusItemController( store: store, settings: settings, account: fetcher.loadAccountInfo(), updater: DisabledUpdaterController(), preferencesSelection: PreferencesSelection(), statusBar: .system) defer { controller.releaseStatusItemsForTesting() } controller.updateIcons() #expect(controller.primaryProviderForUnifiedIcon() == .claude) #expect(controller._test_isMenuBarCountdownRefreshScheduled()) } }