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

202 lines
9.6 KiB
Swift

import CodexBarCore
import Foundation
import Testing
@testable import CodexBar
/// Covers `normalRefreshIntervalForHeuristics()` and every consumer that previously read
/// `RefreshFrequency.seconds` directly. That property is nil for both `.manual` and `.adaptive`,
/// so without the helper the interval-derived heuristics (reset-boundary refresh, OpenAI web
/// staleness, persistent-CLI-session idle windows) silently degrade to manual behavior the
/// moment a user picks adaptive. Each consumer has a test here that goes red if its call site
/// is reverted to `.seconds`.
@MainActor
struct AdaptiveRefreshHeuristicsTests {
@Test
func `manual keeps the heuristics interval nil`() {
let store = Self.makeStore(suite: "heuristics-manual-nil", frequency: .manual)
#expect(store.normalRefreshIntervalForHeuristics() == nil)
}
@Test(arguments: [
(RefreshFrequency.oneMinute, 60.0),
(.twoMinutes, 120.0),
(.fiveMinutes, 300.0),
(.fifteenMinutes, 900.0),
(.thirtyMinutes, 1800.0)
])
func `fixed frequencies pass their configured seconds through`(
frequency: RefreshFrequency,
expectedSeconds: TimeInterval)
{
let store = Self.makeStore(suite: "heuristics-fixed-\(frequency.rawValue)", frequency: frequency)
#expect(store.normalRefreshIntervalForHeuristics() == expectedSeconds)
}
@Test
func `adaptive resolves to the live adaptive decision delay`() {
let store = Self.makeStore(suite: "heuristics-adaptive-live", frequency: .adaptive)
// No recorded menu open: the decision is longIdle, or constrained on a low-power/hot
// machine — both are 30 minutes, so this assertion is environment-independent.
#expect(store.normalRefreshIntervalForHeuristics() == 1800.0)
store.noteMenuOpened()
let expected = TimeInterval(UsageStore.adaptiveRefreshDecision(
now: Date(),
lastMenuOpenAt: store.lastMenuOpenAt,
lowPowerModeEnabled: ProcessInfo.processInfo.isLowPowerModeEnabled,
thermalState: ProcessInfo.processInfo.thermalState).delay.components.seconds)
#expect(store.normalRefreshIntervalForHeuristics() == expected)
if Self.machineIsUnconstrained {
#expect(store.normalRefreshIntervalForHeuristics() == 120.0)
}
}
@Test
func `adaptive cadence schedules a reset-boundary refresh through the refresh pipeline`() async {
let store = Self.makeStoreWithStubbedCodex(suite: "heuristics-boundary-adaptive", frequency: .adaptive)
// Goes through the real end-of-refresh scheduling call, which must feed the adaptive
// interval (30 min here — no menu open) rather than the nil `RefreshFrequency.seconds`.
await store.refresh()
defer { store.cancelResetBoundaryRefresh() }
#expect(store.scheduledResetBoundaryRefreshAt != nil)
}
@Test
func `manual cadence still never schedules a reset-boundary refresh through the refresh pipeline`() async {
let store = Self.makeStoreWithStubbedCodex(suite: "heuristics-boundary-manual", frequency: .manual)
await store.refresh()
defer { store.cancelResetBoundaryRefresh() }
#expect(store.scheduledResetBoundaryRefreshAt == nil)
}
@Test
func `adaptive mode lifts the openai web refresh interval off the manual floor`() {
let adaptiveStore = Self.makeStore(suite: "heuristics-web-adaptive", frequency: .adaptive)
let manualStore = Self.makeStore(suite: "heuristics-web-manual", frequency: .manual)
let adaptiveInterval = adaptiveStore.openAIWebRefreshIntervalSeconds()
let manualInterval = manualStore.openAIWebRefreshIntervalSeconds()
// Manual hits the 120s fallback floor; adaptive with no menu open resolves to 1800s.
// Comparing as a ratio keeps this independent of the web-refresh multiplier.
#expect(manualInterval > 0)
#expect(adaptiveInterval == manualInterval * 15)
}
@Test
func `registry nominal interval maps adaptive to the policy nominal and keeps manual nil`() {
#expect(ProviderRegistry.nominalRefreshInterval(for: .adaptive)
== AdaptiveRefreshPolicy.nominalIntervalForHeuristics)
#expect(ProviderRegistry.nominalRefreshInterval(for: .manual) == nil)
#expect(ProviderRegistry.nominalRefreshInterval(for: .thirtyMinutes) == 1800.0)
}
@Test
func `provider specs give adaptive a nominal cli session idle window instead of the floor`() {
let adaptiveStore = Self.makeStore(suite: "heuristics-spec-adaptive", frequency: .adaptive)
let manualStore = Self.makeStore(suite: "heuristics-spec-manual", frequency: .manual)
// Registry specs have no UsageStore to ask, so adaptive maps to the policy's nominal
// 300s steady-state interval: max(180, 300 + 60) = 360.
let adaptiveWindow = adaptiveStore.providerSpecs[.codex]?
.makeFetchContext().persistentCLISessionIdleWindow
let manualWindow = manualStore.providerSpecs[.codex]?
.makeFetchContext().persistentCLISessionIdleWindow
#expect(adaptiveWindow == 360)
#expect(manualWindow == 180)
}
@Test
func `account-scoped fetch contexts derive the idle window from the live adaptive interval`() {
let adaptiveStore = Self.makeStore(suite: "heuristics-account-adaptive", frequency: .adaptive)
let manualStore = Self.makeStore(suite: "heuristics-account-manual", frequency: .manual)
// Unlike registry specs, this path runs inside UsageStore, so adaptive uses the live
// decision: 1800s with no menu open, giving max(180, 1800 + 60) = 1860.
let adaptiveWindow = adaptiveStore
.makeFetchContext(provider: .codex, override: nil).persistentCLISessionIdleWindow
let manualWindow = manualStore
.makeFetchContext(provider: .codex, override: nil).persistentCLISessionIdleWindow
#expect(adaptiveWindow == 1860)
#expect(manualWindow == 180)
}
private static var machineIsUnconstrained: Bool {
let thermalState = ProcessInfo.processInfo.thermalState
return !ProcessInfo.processInfo.isLowPowerModeEnabled
&& (thermalState == .nominal || thermalState == .fair)
}
private static func makeStore(suite: String, frequency: RefreshFrequency) -> UsageStore {
let settings = testSettingsStore(suiteName: "AdaptiveRefreshHeuristicsTests-\(suite)")
settings.providerDetectionCompleted = true
settings.refreshFrequency = frequency
Self.disableAllProviders(settings: settings)
return UsageStore(
fetcher: UsageFetcher(environment: [:]),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
startupBehavior: .testing,
environmentBase: [:])
}
/// The reset-boundary pipeline tests need `refresh()` to complete with a snapshot still in
/// place, and `clearDisabledProviderRefreshState` wipes snapshots of disabled providers. So
/// codex stays enabled but its fetch is stubbed to return a canned snapshot whose primary
/// window resets 10 minutes out — inside a 30-minute normal-refresh window, outside nothing.
/// The live-system account is pinned and the snapshot carries the same email, so the
/// account-scoped apply guard resolves identically whether or not the machine running the
/// tests has a real `~/.codex` login (CI runners do not).
private static func makeStoreWithStubbedCodex(suite: String, frequency: RefreshFrequency) -> UsageStore {
let store = Self.makeStore(suite: suite, frequency: frequency)
let metadata = ProviderRegistry.shared.metadata[.codex]!
store.settings._test_liveSystemCodexAccount = ObservedSystemCodexAccount(
email: Self.stubbedCodexEmail,
codexHomePath: "/Users/test/.codex",
observedAt: Date(),
identity: .unresolved)
store.settings.codexActiveSource = .liveSystem
store.settings.setProviderEnabled(provider: .codex, metadata: metadata, enabled: true)
store.providerSpecs[.codex] = CodexAccountScopedRefreshTests.makeCodexProviderSpec(
baseSpec: store.providerSpecs[.codex]!)
{
Self.snapshot(updatedAt: Date(), primaryResetsAt: Date().addingTimeInterval(10 * 60))
}
return store
}
private nonisolated static let stubbedCodexEmail = "adaptive-heuristics@example.com"
/// Keeps `refresh()` cheap and deterministic: no provider fetch can replace the snapshot
/// injected by the reset-boundary tests or slow the pipeline tests down.
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 nonisolated static func snapshot(updatedAt: Date, primaryResetsAt: Date) -> UsageSnapshot {
UsageSnapshot(
primary: RateWindow(
usedPercent: 100,
windowMinutes: 300,
resetsAt: primaryResetsAt,
resetDescription: nil),
secondary: nil,
tertiary: nil,
updatedAt: updatedAt,
identity: ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: self.stubbedCodexEmail,
accountOrganization: nil,
loginMethod: "Pro"))
}
}