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

418 lines
19 KiB
Swift

import CodexBarCore
import Foundation
import Testing
@testable import CodexBar
/// Tests for the startup async plan-utilization history load.
///
/// The decode of the persisted `PlanUtilizationHistoryStore` is moved off the
/// startup main thread because a mature two-year history can take ~150 ms to
/// parse. These tests pin the contract:
/// - `UsageStore.init` returns before disk I/O completes
/// - the load publishes exactly once after the gate releases
/// - sync menu accessors return the empty stub (no migration, no persistence
/// enqueue) while the load is in flight
/// - mutation paths wait for the load before touching the dictionary so a
/// startup refresh cannot overwrite real disk history with empty stubs
struct UsageStorePlanUtilizationAsyncLoadTests {
@MainActor
@Test
func `testing startup without an injected history store skips disk loading`() {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-default-test-\(UUID().uuidString)"
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
startupBehavior: .testing)
#expect(store.planUtilizationHistoryLoadTask == nil)
#expect(store.planUtilizationHistoryLoaded == true)
#expect(store.planUtilizationHistory.isEmpty)
#expect(store.planUtilizationHistoryStore.directoryURL == nil)
}
@MainActor
@Test
func `testing startup without an explicit gate skips background load`() {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-testing-\(UUID().uuidString)"
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
historyStore.save([.codex: PlanUtilizationHistoryBuckets(
preferredAccountKey: nil,
unscoped: [planSeries(
name: .session,
windowMinutes: 300,
entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 42)])],
accounts: [:])])
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing)
#expect(store.planUtilizationHistoryLoadTask == nil)
#expect(store.planUtilizationHistoryLoaded)
#expect(store.planUtilizationHistory.isEmpty)
}
@MainActor
@Test
func `init returns before disk load completes`() {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-init-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: false)
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
_ = settings // silence unused
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
defer { store._cancelPlanUtilizationHistoryLoadForTesting() }
// The gate is still closed, so the background load has not run.
#expect(store.planUtilizationHistory.isEmpty)
#expect(store.planUtilizationHistoryLoaded == false)
#expect(gate.isOpen == false)
}
@MainActor
@Test
func `gate release publishes loaded history and bumps revision once`() async {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-release-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
let codexSeries = planSeries(
name: .session,
windowMinutes: 300,
entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 42)])
let buckets = PlanUtilizationHistoryBuckets(
preferredAccountKey: nil,
unscoped: [codexSeries],
accounts: [:])
historyStore.save([.codex: buckets])
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
_ = settings
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
defer { store._cancelPlanUtilizationHistoryLoadForTesting() }
let revisionBeforeOpen = store.planUtilizationHistoryRevision
gate.open()
await store._waitForPlanUtilizationHistoryLoadForTesting()
#expect(store.planUtilizationHistoryLoaded == true)
#expect(store.planUtilizationHistory[.codex]?.unscoped.first?.name == .session)
// Revision must increment by exactly one when the load completes.
#expect(store.planUtilizationHistoryRevision == revisionBeforeOpen + 1)
}
@MainActor
@Test
func `sync menu accessor returns empty stub while loading`() {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-menuGate-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
// Pre-populate disk so a loaded store would return real history.
let series = planSeries(
name: .weekly,
windowMinutes: 10080,
entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 88)])
historyStore.save([.claude: PlanUtilizationHistoryBuckets(
preferredAccountKey: nil,
unscoped: [series],
accounts: [:])])
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
_ = settings
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
defer { store._cancelPlanUtilizationHistoryLoadForTesting() }
let selection = store.planUtilizationHistorySelection(for: .claude)
#expect(selection.accountKey == nil)
#expect(selection.histories.isEmpty)
#expect(store.planUtilizationHistory[.claude]?.preferredAccountKey == nil)
}
@MainActor
@Test
func `empty directory loads to empty dictionary without error`() async {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-empty-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
_ = settings
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
defer { store._cancelPlanUtilizationHistoryLoadForTesting() }
gate.open()
await store._waitForPlanUtilizationHistoryLoadForTesting()
#expect(store.planUtilizationHistory.isEmpty)
#expect(store.planUtilizationHistoryLoaded == true)
}
@MainActor
@Test
func `corrupt file loads best-effort empty`() async throws {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-corrupt-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
// Write a file that does not parse as the expected schema.
let directoryURL = try #require(historyStore.directoryURL)
try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let badURL = directoryURL.appendingPathComponent("codex.json")
try? Data("{not valid json".utf8).write(to: badURL)
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
_ = settings
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
defer { store._cancelPlanUtilizationHistoryLoadForTesting() }
gate.open()
await store._waitForPlanUtilizationHistoryLoadForTesting()
// Best-effort empty: no panic, no providers populated, loaded flag set.
#expect(store.planUtilizationHistory.isEmpty)
#expect(store.planUtilizationHistoryLoaded == true)
}
@MainActor
@Test
func `multi-provider multi-account ownership preserved after load`() async {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-multi-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
let codexSession = planSeries(
name: .session,
windowMinutes: 300,
entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 31)])
let claudeWeekly = planSeries(
name: .weekly,
windowMinutes: 10080,
entries: [planEntry(at: Date(timeIntervalSince1970: 1_700_000_001), usedPercent: 65)])
let accountKey = "hashed-account-key"
let buckets = PlanUtilizationHistoryBuckets(
preferredAccountKey: accountKey,
unscoped: [],
accounts: [accountKey: [codexSession, claudeWeekly]])
historyStore.save([.codex: buckets, .claude: buckets])
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
_ = settings
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
defer { store._cancelPlanUtilizationHistoryLoadForTesting() }
gate.open()
await store._waitForPlanUtilizationHistoryLoadForTesting()
#expect(store.planUtilizationHistory[.codex]?.accounts[accountKey]?.count == 2)
#expect(store.planUtilizationHistory[.claude]?.accounts[accountKey]?.count == 2)
#expect(store.planUtilizationHistory[.codex]?.preferredAccountKey == accountKey)
}
@MainActor
@Test
func `record waits for disk load then merges and persists history`() async {
let suiteName = "UsageStorePlanUtilizationAsyncLoad-record-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
let oldCapture = Date(timeIntervalSince1970: 1_700_000_000)
let newCapture = oldCapture.addingTimeInterval(3700)
historyStore.save([.claude: PlanUtilizationHistoryBuckets(
preferredAccountKey: nil,
unscoped: [planSeries(
name: .session,
windowMinutes: 300,
entries: [planEntry(at: oldCapture, usedPercent: 20)])],
accounts: [:])])
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
let snapshot = UsageSnapshot(
primary: RateWindow(
usedPercent: 42,
windowMinutes: 300,
resetsAt: nil,
resetDescription: nil),
secondary: nil,
updatedAt: newCapture,
identity: nil)
var recordStarted = false
var recordCompleted = false
let recordTask = Task { @MainActor in
recordStarted = true
await store.recordPlanUtilizationHistorySample(
provider: .claude,
snapshot: snapshot,
now: newCapture)
recordCompleted = true
}
for _ in 0..<1000 where !recordStarted {
await Task.yield()
}
#expect(recordStarted)
#expect(!recordCompleted)
#expect(store.planUtilizationHistory.isEmpty)
gate.open()
await store._waitForPlanUtilizationHistoryLoadForTesting()
await recordTask.value
let inMemory = findSeries(
store.planUtilizationHistory[.claude]?.unscoped ?? [],
name: .session,
windowMinutes: 300)
var persisted: PlanUtilizationSeriesHistory?
for _ in 0..<100 {
persisted = findSeries(
historyStore.load()[.claude]?.unscoped ?? [],
name: .session,
windowMinutes: 300)
if persisted == inMemory { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(inMemory?.entries.map(\.capturedAt) == [oldCapture, newCapture])
#expect(inMemory?.entries.map(\.usedPercent) == [20, 42])
#expect(persisted == inMemory)
}
@MainActor
@Test
func `init work is independent of history size`() throws {
// With a closed load gate, UsageStore.init must return even when the
// persisted history would dominate startup time at production scale.
// The closed gate decouples the assertion from wall-clock variance;
// we verify the init returned before the load completed, not the
// decode duration itself.
let suiteName = "UsageStorePlanUtilizationAsyncLoad-perf-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
// Write a multi-megabyte synthetic payload so a real load would block.
let directoryURL = try #require(historyStore.directoryURL)
try? FileManager.default.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let bigURL = directoryURL.appendingPathComponent("codex.json")
let payload = Self.makeSyntheticHistoryPayload(entriesPerProvider: 50000)
try? payload.write(to: bigURL)
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
_ = settings
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
defer { store._cancelPlanUtilizationHistoryLoadForTesting() }
// Init returned without waiting on the disk load.
#expect(store.planUtilizationHistoryLoaded == false)
#expect(gate.isOpen == false)
}
@MainActor
@Test
func `cancel before load wait is registered still drains the task`() async throws {
// Cancel immediately after init, intentionally without yielding. The
// cancellation state must remain visible when the load task later
// reaches `wait()`; otherwise the wakeup can be lost and the task leaks.
let suiteName = "UsageStorePlanUtilizationAsyncLoad-cancel-\(UUID().uuidString)"
let gate = PlanUtilizationHistoryLoadGate()
let historyStore = testPlanUtilizationHistoryStore(suiteName: suiteName, reset: true)
let settings = Self.makeSettings(suiteName: suiteName)
defer { UserDefaults().removePersistentDomain(forName: suiteName) }
_ = settings
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings,
planUtilizationHistoryStore: historyStore,
startupBehavior: .testing,
planUtilizationHistoryLoadGateForTesting: gate)
let loadTask = try #require(store.planUtilizationHistoryLoadTask)
store._cancelPlanUtilizationHistoryLoadForTesting()
await loadTask.value
#expect(gate.isCancelled == true)
#expect(store.planUtilizationHistoryLoaded == true)
#expect(store.planUtilizationHistory.isEmpty)
gate.open()
#expect(gate.isOpen == false)
}
// MARK: - Helpers
@MainActor
private static func makeSettings(suiteName: String) -> SettingsStore {
let defaults = UserDefaults(suiteName: suiteName) ?? UserDefaults.standard
defaults.removePersistentDomain(forName: suiteName)
return SettingsStore(
userDefaults: defaults,
configStore: testConfigStore(suiteName: suiteName),
tokenAccountStore: InMemoryTokenAccountStore())
}
private static func makeSyntheticHistoryPayload(entriesPerProvider: Int) -> Data {
// A non-decodable but valid JSON shape keeps the test independent of
// the schema version while still forcing the JSON decoder to do real
// work when the load runs.
var entries: [String] = []
entries.reserveCapacity(entriesPerProvider)
for index in 0..<entriesPerProvider {
entries.append("{\"i\":\(index),\"p\":0.5,\"r\":\"2026-01-01T00:00:00Z\"}")
}
let body = "{\"v\":1,\"u\":[\(entries.joined(separator: ","))],\"a\":{}}"
return Data(body.utf8)
}
}