import CodexBarCore import Foundation import Testing @testable import CodexBar /// Covers the timer plumbing added on top of the pure `AdaptiveRefreshPolicy` (see /// `AdaptiveRefreshPolicyTests`): how `UsageStore.startTimer()` wires live signals into the /// policy, and how manual/fixed/adaptive modes drive (or don't drive) `refresh()` over time. @MainActor struct AdaptiveRefreshTimerTests { @Test func `launch with no menu history begins at thirty minutes`() { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-launch", frequency: .adaptive) let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) #expect(store.lastMenuOpenAt == nil) let decision = UsageStore.adaptiveRefreshDecision( now: Date(), lastMenuOpenAt: store.lastMenuOpenAt, lowPowerModeEnabled: false, thermalState: .nominal) #expect(decision.reason == .longIdle) #expect(decision.delay == .seconds(30 * 60)) } @Test func `menu-open signal changes the next adaptive decision`() { let now = Date(timeIntervalSinceReferenceDate: 900_000_000) let beforeOpen = UsageStore.adaptiveRefreshDecision( now: now, lastMenuOpenAt: nil, lowPowerModeEnabled: false, thermalState: .nominal) #expect(beforeOpen.reason == .longIdle) let afterOpen = UsageStore.adaptiveRefreshDecision( now: now, lastMenuOpenAt: now, lowPowerModeEnabled: false, thermalState: .nominal) #expect(afterOpen.reason == .recentInteraction) } @Test func `menu open advances a long idle timer during refresh without postponing an earlier tick`() async throws { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-advance", frequency: .adaptive) let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) store.restartTimerWithSleepOverrideForTesting(.seconds(10)) try await Self.waitUntil { store.adaptiveRefreshScheduledAt != nil } let longIdleSchedule = try #require(store.adaptiveRefreshScheduledAt) let now = Date() store._setSnapshotForTesting( UsageSnapshot( primary: RateWindow( usedPercent: 50, windowMinutes: 300, resetsAt: now.addingTimeInterval(30), resetDescription: nil), secondary: nil, updatedAt: now, identity: nil), provider: .codex) store.scheduleResetBoundaryRefreshIfNeeded(normalRefreshInterval: 30 * 60, now: now) defer { store.cancelResetBoundaryRefresh() } let resetBoundarySchedule = try #require(store.scheduledResetBoundaryRefreshAt) store.isRefreshing = true defer { store.isRefreshing = false } store.noteMenuOpened() try await Self.waitUntil { guard let scheduledAt = store.adaptiveRefreshScheduledAt else { return false } return scheduledAt < longIdleSchedule } let interactionSchedule = try #require(store.adaptiveRefreshScheduledAt) #expect(store.isRefreshing) #expect(store.scheduledResetBoundaryRefreshAt == resetBoundarySchedule) store.noteMenuOpened(at: Date().addingTimeInterval(30)) #expect(store.adaptiveRefreshScheduledAt == interactionSchedule) } @Test func `noting a menu open records the signal without starting a refresh`() { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-noteMenuOpened", frequency: .manual) let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) #expect(store.completedRefreshCountForTesting == 0) #expect(store.isRefreshing == false) store.noteMenuOpened() #expect(store.lastMenuOpenAt != nil) #expect(store.completedRefreshCountForTesting == 0) #expect(store.isRefreshing == false) } @Test func `opportunistic timer refresh is a no-op while another refresh is already in flight`() async { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-coalesce", frequency: .manual) let store = Self.makeUsageStore(settings: settings, startupBehavior: .testing) store.isRefreshing = true await store.refresh(enrichmentMode: .automatic) // The guard at the top of runRefresh() returned immediately: no completion was recorded and the // flag was left untouched by this call. This is the invariant every timer tick (fixed or // adaptive) relies on to avoid overlapping with a refresh already in flight. #expect(store.completedRefreshCountForTesting == 0) #expect(store.isRefreshing == true) } @Test func `manual mode performs the initial refresh but no recurring ticks`() async throws { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-manual", frequency: .manual) let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } // Manual mode never starts a timer, so nothing can push the count past the one launch refresh // no matter how long we wait; a short settle window is enough to catch a regression. try await Task.sleep(for: .milliseconds(300)) #expect(store.completedRefreshCountForTesting == 1) } @Test func `fixed mode ticks recur at the overridden cadence`() async throws { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-fixed", frequency: .oneMinute) let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) store.restartTimerWithSleepOverrideForTesting(.milliseconds(20)) // 1 initial launch refresh plus at least one 20ms-cadence tick; proves the loop recurs // rather than sleeping once and stopping. Each refresh cycle here costs low single-digit // seconds of wall time even with every provider disabled, so the timeout is generous. try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting >= 2 } #expect(store.completedRefreshCountForTesting >= 2) } @Test func `fixed cadence advances from scheduled tick instead of refresh completion`() { let interval = Duration.milliseconds(100) let start = ContinuousClock.now let firstScheduledAt = start + interval let nextAfterExactTick = UsageStore.nextFixedTimerScheduledAt( previousScheduledAt: firstScheduledAt, completedAt: firstScheduledAt, interval: interval) #expect(nextAfterExactTick == start + .milliseconds(200)) let nextJustBeforeFollowingTick = UsageStore.nextFixedTimerScheduledAt( previousScheduledAt: firstScheduledAt, completedAt: firstScheduledAt + .milliseconds(100) - .nanoseconds(1), interval: interval) #expect(nextJustBeforeFollowingTick == start + .milliseconds(200)) let nextAtFollowingTick = UsageStore.nextFixedTimerScheduledAt( previousScheduledAt: firstScheduledAt, completedAt: firstScheduledAt + .milliseconds(100), interval: interval) #expect(nextAtFollowingTick == start + .milliseconds(300)) let nextAfterSlowRefresh = UsageStore.nextFixedTimerScheduledAt( previousScheduledAt: firstScheduledAt, completedAt: firstScheduledAt + .milliseconds(60), interval: interval) #expect(nextAfterSlowRefresh == start + .milliseconds(200)) let nextAfterMissedTicks = UsageStore.nextFixedTimerScheduledAt( previousScheduledAt: firstScheduledAt, completedAt: firstScheduledAt + .milliseconds(260), interval: interval) #expect(nextAfterMissedTicks == start + .milliseconds(400)) } @Test func `fixed timer loop stays interval aligned after a slow refresh`() async { let harness = FixedTimerLoopHarness() await UsageStore.runFixedRefreshTimer( interval: .milliseconds(100), now: { await harness.now() }, sleep: { duration in await harness.sleep(for: duration) }, refresh: { await harness.refresh() }) #expect(await harness.recordedStarts() == [.milliseconds(100), .milliseconds(300)]) #expect(await harness.maximumConcurrentRefreshes() == 1) } @Test func `adaptive mode keeps recomputing and refreshing across menu-open changes`() async throws { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-adaptive", frequency: .adaptive) let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) store.restartTimerWithSleepOverrideForTesting(.milliseconds(20)) try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting >= 1 } let countBeforeMenuOpen = store.completedRefreshCountForTesting store.noteMenuOpened() // The loop kept looping (recomputing the decision from a fresh Input) after lastMenuOpenAt // changed, rather than sleeping once on a captured delay and stopping. try await Self.waitUntil(timeout: .seconds(45)) { store.completedRefreshCountForTesting > countBeforeMenuOpen } #expect(store.completedRefreshCountForTesting > countBeforeMenuOpen) } @Test func `changing frequency away from fixed cancels the pending tick without an extra refresh`() async throws { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel", frequency: .oneMinute) let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) // Deliberately much longer than anything else in this test: the assertion only needs this // sleep to still be pending (uncompleted) when we switch away, not to time anything precisely. store.restartTimerWithSleepOverrideForTesting(.seconds(5)) // Only the initial launch refresh can land this quickly; the fixed-mode timer's first tick // needs the full 5s override to elapse, so it cannot have fired yet. try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } let countBeforeSwitch = store.completedRefreshCountForTesting settings.refreshFrequency = .manual // The settings-change path (outside adaptive-refresh scope) may fire its own refresh(es) for // reasons unrelated to the timer under test; wait for the count to stop moving rather than // assuming it fires exactly once. Windows are doubled from an earlier version that flaked once // under full parallel `make test` load. let countAfterSettling = try await Self.waitForStableCount(store: store, settleWindow: .milliseconds(800)) #expect(countAfterSettling > countBeforeSwitch) // Settle comfortably within the 5s override window. If the old fixed-mode timer had not been // canceled, its pending tick would eventually land and push the count past the settled value — // but not within this window, so any further increase here indicates a real cancellation bug, // not settings-change noise. try await Task.sleep(for: .milliseconds(1600)) #expect(store.completedRefreshCountForTesting == countAfterSettling) } // The test above goes through `settings.refreshFrequency = .manual`, which also triggers the // settings-observer's own `refreshForSettingsChange()` — a legitimate refresh unrelated to the // timer. That confound means it cannot, by itself, prove the `guard !Task.isCancelled else { return }` // after each branch's sleep is load-bearing (deleting either guard still leaves this test green, // since the settings-observer refresh already accounts for the "count increased" expectation). // The two tests below isolate `startTimer()`'s cancel-and-replace path directly, by calling // `restartTimerWithSleepOverrideForTesting` a second time at the *same* frequency — which goes // straight through `startTimer()` with no settings observation involved — so no refresh is // legitimately expected at all, and any extra one proves a canceled sleep still ran its body. @Test func `restarting the timer cancels a pending fixed tick without an extra refresh`() async throws { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel-fixed", frequency: .oneMinute) let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) store.restartTimerWithSleepOverrideForTesting(.seconds(5)) try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } let countBeforeRestart = store.completedRefreshCountForTesting // Cancels the pending 5s sleep above and starts a fresh one, still at .oneMinute. No settings // mutation, so no settings-observer refresh is expected here at all. store.restartTimerWithSleepOverrideForTesting(.seconds(5)) // Neither the old (canceled) timer's tick nor the new timer's first tick can land within this // window — both need the full 5s override. Any refresh here can only be the canceled sleep's // body running anyway. try await Task.sleep(for: .milliseconds(800)) #expect(store.completedRefreshCountForTesting == countBeforeRestart) } @Test func `restarting the timer cancels a pending adaptive tick without an extra refresh`() async throws { let settings = Self.makeSettingsStore(suite: "AdaptiveRefreshTimerTests-cancel-adaptive", frequency: .adaptive) let store = Self.makeUsageStore(settings: settings, startupBehavior: .full) store.restartTimerWithSleepOverrideForTesting(.seconds(5)) try await Self.waitUntil { store.completedRefreshCountForTesting >= 1 } let countBeforeRestart = store.completedRefreshCountForTesting store.restartTimerWithSleepOverrideForTesting(.seconds(5)) try await Task.sleep(for: .milliseconds(800)) #expect(store.completedRefreshCountForTesting == countBeforeRestart) } /// Polls `condition` until it's true or `timeout` elapses, without assuming how long setup or /// scheduling takes. Throws `CancellationError` (surfaced as a test failure) on timeout. private static func waitUntil( timeout: Duration = .seconds(30), pollInterval: Duration = .milliseconds(20), _ condition: () -> Bool) async throws { let deadline = ContinuousClock.now + timeout while !condition() { if ContinuousClock.now >= deadline { throw CancellationError() } try await Task.sleep(for: pollInterval) } } /// Polls `store.completedRefreshCountForTesting` until it stops changing for `settleWindow`, /// tolerating an unknown number of in-flight refreshes (e.g. settings-change side effects /// unrelated to the timer under test) before returning the final, stable count. private static func waitForStableCount( store: UsageStore, settleWindow: Duration, timeout: Duration = .seconds(30), pollInterval: Duration = .milliseconds(20)) async throws -> Int { let deadline = ContinuousClock.now + timeout var lastCount = store.completedRefreshCountForTesting var lastChangedAt = ContinuousClock.now while true { try await Task.sleep(for: pollInterval) let current = store.completedRefreshCountForTesting let now = ContinuousClock.now if current != lastCount { lastCount = current lastChangedAt = now } else if now - lastChangedAt >= settleWindow { return lastCount } if now >= deadline { throw CancellationError() } } } private static func makeSettingsStore(suite: String, frequency: RefreshFrequency) -> SettingsStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) let configStore = testConfigStore(suiteName: suite) let settings = SettingsStore( userDefaults: defaults, configStore: configStore, zaiTokenStore: NoopZaiTokenStore(), syntheticTokenStore: NoopSyntheticTokenStore(), codexCookieStore: InMemoryCookieHeaderStore(), claudeCookieStore: InMemoryCookieHeaderStore(), cursorCookieStore: InMemoryCookieHeaderStore(), opencodeCookieStore: InMemoryCookieHeaderStore(), factoryCookieStore: InMemoryCookieHeaderStore(), minimaxCookieStore: InMemoryMiniMaxCookieStore(), minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), kimiTokenStore: InMemoryKimiTokenStore(), kimiK2TokenStore: InMemoryKimiK2TokenStore(), augmentCookieStore: InMemoryCookieHeaderStore(), ampCookieStore: InMemoryCookieHeaderStore(), copilotTokenStore: InMemoryCopilotTokenStore(), tokenAccountStore: InMemoryTokenAccountStore()) settings.providerDetectionCompleted = true settings.refreshFrequency = frequency Self.disableAllProviders(settings: settings) return settings } /// Codex is enabled by default; disabling every provider (including it) keeps `refresh()` cheap /// and deterministic in these tests, which care about tick cadence, not provider fetch results. private static func disableAllProviders(settings: SettingsStore) { let metadata = ProviderRegistry.shared.metadata for provider in UsageProvider.allCases { guard let providerMetadata = metadata[provider] else { continue } settings.setProviderEnabled(provider: provider, metadata: providerMetadata, enabled: false) } } private static func makeUsageStore( settings: SettingsStore, startupBehavior: UsageStore.StartupBehavior) -> UsageStore { UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, startupBehavior: startupBehavior, environmentBase: [:]) } } private actor FixedTimerLoopHarness { private let origin = ContinuousClock.now private var elapsed = Duration.zero private var starts: [Duration] = [] private var activeRefreshes = 0 private var maximumActiveRefreshes = 0 func now() -> ContinuousClock.Instant { self.origin + self.elapsed } func sleep(for duration: Duration) { self.elapsed += duration } func refresh() { self.activeRefreshes += 1 self.maximumActiveRefreshes = max(self.maximumActiveRefreshes, self.activeRefreshes) self.starts.append(self.elapsed) if self.starts.count == 1 { self.elapsed += .milliseconds(160) } self.activeRefreshes -= 1 if self.starts.count == 2 { withUnsafeCurrentTask { $0?.cancel() } } } func recordedStarts() -> [Duration] { self.starts } func maximumConcurrentRefreshes() -> Int { self.maximumActiveRefreshes } }