1204 lines
50 KiB
Swift
1204 lines
50 KiB
Swift
import CodexBarCore
|
|
import Foundation
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
// swiftlint:disable:next type_body_length
|
|
struct UsageStorePlanUtilizationTests {
|
|
@Test
|
|
func `coalesces changed usage within hour into single entry`() throws {
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let hourStart = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone(secondsFromGMT: 0),
|
|
year: 2026,
|
|
month: 3,
|
|
day: 17,
|
|
hour: 10)))
|
|
let first = planEntry(at: hourStart, usedPercent: 10)
|
|
let second = planEntry(at: hourStart.addingTimeInterval(25 * 60), usedPercent: 35)
|
|
|
|
let initial = try #require(
|
|
UsageStore._updatedPlanUtilizationEntriesForTesting(
|
|
existingEntries: [],
|
|
entry: first))
|
|
let updated = try #require(
|
|
UsageStore._updatedPlanUtilizationEntriesForTesting(
|
|
existingEntries: initial,
|
|
entry: second))
|
|
|
|
#expect(updated.count == 1)
|
|
#expect(updated.last == second)
|
|
}
|
|
|
|
@Test
|
|
func `changed reset boundary within hour appends new entry`() throws {
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let hourStart = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone(secondsFromGMT: 0),
|
|
year: 2026,
|
|
month: 3,
|
|
day: 17,
|
|
hour: 10)))
|
|
let first = planEntry(
|
|
at: hourStart.addingTimeInterval(5 * 60),
|
|
usedPercent: 82,
|
|
resetsAt: hourStart.addingTimeInterval(30 * 60))
|
|
let second = planEntry(
|
|
at: hourStart.addingTimeInterval(35 * 60),
|
|
usedPercent: 4,
|
|
resetsAt: hourStart.addingTimeInterval(5 * 60 * 60))
|
|
|
|
let initial = try #require(
|
|
UsageStore._updatedPlanUtilizationEntriesForTesting(
|
|
existingEntries: [],
|
|
entry: first))
|
|
let updated = try #require(
|
|
UsageStore._updatedPlanUtilizationEntriesForTesting(
|
|
existingEntries: initial,
|
|
entry: second))
|
|
|
|
#expect(updated.count == 2)
|
|
#expect(updated[0] == first)
|
|
#expect(updated[1] == second)
|
|
}
|
|
|
|
@Test
|
|
func `first known reset boundary within hour replaces earlier provisional peak even when usage drops`() throws {
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let hourStart = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone(secondsFromGMT: 0),
|
|
year: 2026,
|
|
month: 3,
|
|
day: 17,
|
|
hour: 10)))
|
|
let first = planEntry(
|
|
at: hourStart.addingTimeInterval(5 * 60),
|
|
usedPercent: 82,
|
|
resetsAt: nil)
|
|
let second = planEntry(
|
|
at: hourStart.addingTimeInterval(35 * 60),
|
|
usedPercent: 4,
|
|
resetsAt: hourStart.addingTimeInterval(5 * 60 * 60))
|
|
|
|
let initial = try #require(
|
|
UsageStore._updatedPlanUtilizationEntriesForTesting(
|
|
existingEntries: [],
|
|
entry: first))
|
|
let updated = try #require(
|
|
UsageStore._updatedPlanUtilizationEntriesForTesting(
|
|
existingEntries: initial,
|
|
entry: second))
|
|
|
|
#expect(updated.count == 1)
|
|
#expect(updated[0] == second)
|
|
}
|
|
|
|
@Test
|
|
func `trims entry history to retention limit`() throws {
|
|
let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting
|
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
|
var entries: [PlanUtilizationHistoryEntry] = []
|
|
|
|
for offset in 0..<maxSamples {
|
|
entries.append(planEntry(
|
|
at: base.addingTimeInterval(Double(offset) * 3600),
|
|
usedPercent: Double(offset % 100)))
|
|
}
|
|
|
|
let appended = planEntry(
|
|
at: base.addingTimeInterval(Double(maxSamples) * 3600),
|
|
usedPercent: 50)
|
|
|
|
let updated = try #require(
|
|
UsageStore._updatedPlanUtilizationEntriesForTesting(
|
|
existingEntries: entries,
|
|
entry: appended))
|
|
|
|
#expect(updated.count == maxSamples)
|
|
#expect(updated.first?.capturedAt == entries[1].capturedAt)
|
|
#expect(updated.last == appended)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `native chart shows visible series tabs only`() {
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 0, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 90),
|
|
]),
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20),
|
|
]),
|
|
planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 48),
|
|
]),
|
|
]
|
|
|
|
let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting(
|
|
histories: histories,
|
|
provider: .codex)
|
|
|
|
#expect(model.visibleSeries == ["session:300", "weekly:10080"])
|
|
#expect(model.selectedSeries == "session:300")
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `native chart folds near canonical codex windows into single tabs`() {
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 299, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20),
|
|
]),
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_003_600), usedPercent: 30),
|
|
]),
|
|
planSeries(name: .weekly, windowMinutes: 10079, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 40),
|
|
]),
|
|
planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_172_800), usedPercent: 50),
|
|
]),
|
|
]
|
|
|
|
let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting(
|
|
histories: histories,
|
|
provider: .codex)
|
|
|
|
#expect(model.visibleSeries == ["session:300", "weekly:10080"])
|
|
#expect(model.selectedSeries == "session:300")
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `claude history tabs match current snapshot bars`() {
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20),
|
|
]),
|
|
planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 48),
|
|
]),
|
|
planSeries(name: .opus, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 12),
|
|
]),
|
|
]
|
|
let snapshot = UsageSnapshot(
|
|
primary: RateWindow(usedPercent: 3, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
|
|
secondary: RateWindow(usedPercent: 10, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
|
|
tertiary: nil,
|
|
updatedAt: Date(timeIntervalSince1970: 1_700_086_400),
|
|
identity: nil)
|
|
|
|
let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting(
|
|
histories: histories,
|
|
provider: .claude,
|
|
snapshot: snapshot)
|
|
|
|
#expect(model.visibleSeries == ["session:300", "weekly:10080"])
|
|
#expect(model.selectedSeries == "session:300")
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `session chart uses native reset boundaries and fills missing windows`() throws {
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let firstBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 4,
|
|
hour: 10)))
|
|
let thirdBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 4,
|
|
hour: 20)))
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary),
|
|
planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: thirdBoundary),
|
|
]),
|
|
]
|
|
|
|
let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting(
|
|
selectedSeriesRawValue: "session:300",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: thirdBoundary)
|
|
|
|
#expect(model.pointCount == 3)
|
|
#expect(model.usedPercents == [62, 0, 20])
|
|
#expect(model.pointDates == [
|
|
formattedBoundary(firstBoundary),
|
|
formattedBoundary(firstBoundary.addingTimeInterval(5 * 60 * 60)),
|
|
formattedBoundary(thirdBoundary),
|
|
])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `session chart labels only day changes`() throws {
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let firstBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 4,
|
|
hour: 20)))
|
|
let thirdBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 5,
|
|
hour: 6)))
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary),
|
|
planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: thirdBoundary),
|
|
]),
|
|
]
|
|
|
|
let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting(
|
|
selectedSeriesRawValue: "session:300",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: thirdBoundary)
|
|
|
|
#expect(model.axisIndexes == [0])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `session chart labels every second day change`() throws {
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let firstBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 4,
|
|
hour: 20)))
|
|
let secondBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 5,
|
|
hour: 6)))
|
|
let thirdBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 6,
|
|
hour: 6)))
|
|
let fourthBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 7,
|
|
hour: 6)))
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary),
|
|
planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: secondBoundary),
|
|
planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 35, resetsAt: thirdBoundary),
|
|
planEntry(at: fourthBoundary.addingTimeInterval(-30 * 60), usedPercent: 18, resetsAt: fourthBoundary),
|
|
]),
|
|
]
|
|
|
|
let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting(
|
|
selectedSeriesRawValue: "session:300",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: fourthBoundary)
|
|
|
|
#expect(model.axisIndexes == [0])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `session chart drops trailing day label when it would clip at chart edge`() throws {
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let firstBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 4,
|
|
hour: 20)))
|
|
let secondBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 5,
|
|
hour: 6)))
|
|
let thirdBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 6,
|
|
hour: 6)))
|
|
let fourthBoundary = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone.current,
|
|
year: 2026,
|
|
month: 3,
|
|
day: 7,
|
|
hour: 20)))
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary),
|
|
planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: secondBoundary),
|
|
planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 35, resetsAt: thirdBoundary),
|
|
planEntry(at: fourthBoundary.addingTimeInterval(-30 * 60), usedPercent: 18, resetsAt: fourthBoundary),
|
|
]),
|
|
]
|
|
|
|
let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting(
|
|
selectedSeriesRawValue: "session:300",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: fourthBoundary)
|
|
|
|
#expect(model.axisIndexes == [0, 10])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `detail line shows used and wasted without provenance copy`() {
|
|
let boundary = Date(timeIntervalSince1970: 1_710_000_000)
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: boundary),
|
|
]),
|
|
]
|
|
|
|
let detail = PlanUtilizationHistoryChartMenuView._detailLineForTesting(
|
|
selectedSeriesRawValue: "session:300",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: boundary.addingTimeInterval(-1))
|
|
|
|
#expect(detail.contains("48% used"))
|
|
#expect(!detail.contains("Provider-reported"))
|
|
#expect(!detail.contains("Estimated"))
|
|
#expect(!detail.contains("wasted"))
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `detail line shows dash for missing window`() {
|
|
let boundary = Date(timeIntervalSince1970: 1_710_000_000)
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: boundary),
|
|
]),
|
|
]
|
|
|
|
let detail = PlanUtilizationHistoryChartMenuView._detailLineForTesting(
|
|
selectedSeriesRawValue: "session:300",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: boundary.addingTimeInterval(5 * 60 * 60))
|
|
|
|
#expect(detail.contains(": -"))
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `detail line keeps zero percent for observed zero usage`() {
|
|
let boundary = Date(timeIntervalSince1970: 1_710_000_000)
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 0, resetsAt: boundary),
|
|
]),
|
|
]
|
|
|
|
let detail = PlanUtilizationHistoryChartMenuView._detailLineForTesting(
|
|
selectedSeriesRawValue: "session:300",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: boundary.addingTimeInterval(-1))
|
|
|
|
#expect(detail.contains("0% used"))
|
|
#expect(!detail.contains(": -"))
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `detail line uses lowercase am pm for session hover`() {
|
|
let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage")
|
|
UserDefaults.standard.set("en", forKey: "appLanguage")
|
|
defer {
|
|
if let previousLanguage {
|
|
UserDefaults.standard.set(previousLanguage, forKey: "appLanguage")
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: "appLanguage")
|
|
}
|
|
}
|
|
|
|
let boundary = Date(timeIntervalSince1970: 1_710_048_000) // Mar 11, 2024 1:20 pm UTC
|
|
let histories = [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: boundary),
|
|
]),
|
|
]
|
|
|
|
let detail = PlanUtilizationHistoryChartMenuView._detailLineForTesting(
|
|
selectedSeriesRawValue: "session:300",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: boundary.addingTimeInterval(-1))
|
|
|
|
#expect(detail.contains(" am") || detail.contains(" pm"))
|
|
#expect(!detail.contains("PM"))
|
|
#expect(!detail.contains("AM"))
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `detail line uses lowercase am pm for weekly hover`() {
|
|
let previousLanguage = UserDefaults.standard.object(forKey: "appLanguage")
|
|
UserDefaults.standard.set("en", forKey: "appLanguage")
|
|
defer {
|
|
if let previousLanguage {
|
|
UserDefaults.standard.set(previousLanguage, forKey: "appLanguage")
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: "appLanguage")
|
|
}
|
|
}
|
|
|
|
let boundary = Date(timeIntervalSince1970: 1_710_048_000) // Mar 11, 2024 1:20 pm UTC
|
|
let histories = [
|
|
planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: boundary),
|
|
]),
|
|
]
|
|
|
|
let detail = PlanUtilizationHistoryChartMenuView._detailLineForTesting(
|
|
selectedSeriesRawValue: "weekly:10080",
|
|
histories: histories,
|
|
provider: .codex,
|
|
referenceDate: boundary.addingTimeInterval(-1))
|
|
|
|
#expect(detail.contains(" am") || detail.contains(" pm"))
|
|
#expect(!detail.contains("PM"))
|
|
#expect(!detail.contains("AM"))
|
|
}
|
|
|
|
@Test
|
|
func `chart empty state shows series specific message`() {
|
|
let text = PlanUtilizationHistoryChartMenuView._emptyStateTextForTesting(title: "Session")
|
|
#expect(text == "No session utilization data yet.")
|
|
}
|
|
|
|
@Test
|
|
func `chart empty state shows series specific message when not refreshing`() {
|
|
let text = PlanUtilizationHistoryChartMenuView._emptyStateTextForTesting(title: "Weekly")
|
|
#expect(text == "No weekly utilization data yet.")
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `plan history selects current account bucket`() throws {
|
|
let store = Self.makeStore()
|
|
let aliceSnapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com")
|
|
let bobSnapshot = Self.makeSnapshot(provider: .codex, email: "bob@example.com")
|
|
let aliceKey = try #require(
|
|
UsageStore._planUtilizationAccountKeyForTesting(
|
|
provider: .codex,
|
|
snapshot: aliceSnapshot))
|
|
let bobKey = try #require(
|
|
UsageStore._planUtilizationAccountKeyForTesting(
|
|
provider: .codex,
|
|
snapshot: bobSnapshot))
|
|
|
|
let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 90),
|
|
])
|
|
let aliceWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20),
|
|
])
|
|
let bobWeekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 50),
|
|
])
|
|
|
|
store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(
|
|
unscoped: [bootstrap],
|
|
accounts: [
|
|
aliceKey: [aliceWeekly],
|
|
bobKey: [bobWeekly],
|
|
])
|
|
|
|
store._setSnapshotForTesting(aliceSnapshot, provider: .codex)
|
|
let aliceHistory = store.planUtilizationHistory(for: .codex)
|
|
#expect(store.planUtilizationHistory[.codex]?.preferredAccountKey == aliceKey)
|
|
#expect(aliceHistory == [aliceWeekly])
|
|
#expect(store.planUtilizationHistory[.codex]?.unscoped == [bootstrap])
|
|
|
|
store._setSnapshotForTesting(bobSnapshot, provider: .codex)
|
|
let bobHistory = store.planUtilizationHistory(for: .codex)
|
|
#expect(store.planUtilizationHistory[.codex]?.preferredAccountKey == bobKey)
|
|
#expect(bobHistory == [bobWeekly])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `plan utilization menu hides while refreshing without current snapshot`() throws {
|
|
let store = Self.makeStore()
|
|
let claudeKey = try #require(
|
|
UsageStore._planUtilizationAccountKeyForTesting(
|
|
provider: .claude,
|
|
snapshot: Self.makeSnapshot(provider: .claude, email: "alice@example.com")))
|
|
let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 64),
|
|
])
|
|
store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(
|
|
preferredAccountKey: claudeKey,
|
|
accounts: [
|
|
claudeKey: [weekly],
|
|
])
|
|
store.refreshingProviders.insert(.claude)
|
|
store._setSnapshotForTesting(nil, provider: .claude)
|
|
|
|
#expect(store.shouldShowRefreshingMenuCard(for: .claude))
|
|
#expect(store.shouldHidePlanUtilizationMenuItem(for: .claude))
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `plan utilization menu stays visible with stored snapshot even during refresh`() throws {
|
|
let store = Self.makeStore()
|
|
let codexSnapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com")
|
|
let codexKey = try #require(
|
|
UsageStore._planUtilizationAccountKeyForTesting(
|
|
provider: .codex,
|
|
snapshot: codexSnapshot))
|
|
let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 64),
|
|
])
|
|
store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(
|
|
preferredAccountKey: codexKey,
|
|
accounts: [
|
|
codexKey: [weekly],
|
|
])
|
|
store.refreshingProviders.insert(.codex)
|
|
store._setSnapshotForTesting(codexSnapshot, provider: .codex)
|
|
|
|
#expect(!store.shouldShowRefreshingMenuCard(for: .codex))
|
|
#expect(!store.shouldHidePlanUtilizationMenuItem(for: .codex))
|
|
let histories = store.planUtilizationHistory(for: .codex)
|
|
#expect(store.planUtilizationHistory[.codex]?.preferredAccountKey == codexKey)
|
|
#expect(histories == [weekly])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `codex plan utilization menu hides during provider only refresh without snapshot`() {
|
|
let store = Self.makeStore()
|
|
store.refreshingProviders.insert(.codex)
|
|
store._setSnapshotForTesting(nil, provider: .codex)
|
|
|
|
#expect(store.shouldShowRefreshingMenuCard(for: .codex))
|
|
#expect(store.shouldHidePlanUtilizationMenuItem(for: .codex))
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `record plan history persists named series from snapshot`() async {
|
|
let store = Self.makeStore()
|
|
let primaryReset = Date(timeIntervalSince1970: 1_710_000_000)
|
|
let secondaryReset = Date(timeIntervalSince1970: 1_710_086_400)
|
|
let snapshot = UsageSnapshot(
|
|
primary: RateWindow(
|
|
usedPercent: 110,
|
|
windowMinutes: 300,
|
|
resetsAt: primaryReset,
|
|
resetDescription: "5h"),
|
|
secondary: RateWindow(
|
|
usedPercent: -20,
|
|
windowMinutes: 10080,
|
|
resetsAt: secondaryReset,
|
|
resetDescription: "7d"),
|
|
updatedAt: Date(),
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .codex,
|
|
accountEmail: "alice@example.com",
|
|
accountOrganization: nil,
|
|
loginMethod: "plus"))
|
|
store._setSnapshotForTesting(snapshot, provider: .codex)
|
|
|
|
await store.recordPlanUtilizationHistorySample(
|
|
provider: .codex,
|
|
snapshot: snapshot,
|
|
now: Date(timeIntervalSince1970: 1_700_000_000))
|
|
|
|
let histories = store.planUtilizationHistory(for: .codex)
|
|
#expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 100)
|
|
#expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.resetsAt == primaryReset)
|
|
#expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 0)
|
|
#expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.resetsAt == secondaryReset)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `record plan history skips invalid zero minute windows`() async {
|
|
let store = Self.makeStore()
|
|
let snapshot = UsageSnapshot(
|
|
primary: RateWindow(
|
|
usedPercent: 25,
|
|
windowMinutes: 0,
|
|
resetsAt: Date(timeIntervalSince1970: 1_710_000_000),
|
|
resetDescription: nil),
|
|
secondary: RateWindow(
|
|
usedPercent: 44,
|
|
windowMinutes: 10080,
|
|
resetsAt: Date(timeIntervalSince1970: 1_710_086_400),
|
|
resetDescription: nil),
|
|
updatedAt: Date(),
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .codex,
|
|
accountEmail: "alice@example.com",
|
|
accountOrganization: nil,
|
|
loginMethod: "plus"))
|
|
store._setSnapshotForTesting(snapshot, provider: .codex)
|
|
|
|
await store.recordPlanUtilizationHistorySample(
|
|
provider: .codex,
|
|
snapshot: snapshot,
|
|
now: Date(timeIntervalSince1970: 1_700_000_000))
|
|
|
|
let histories = store.planUtilizationHistory(for: .codex)
|
|
#expect(findSeries(histories, name: .session, windowMinutes: 0) == nil)
|
|
#expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 44)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `record plan history keeps semantic codex lanes when durations drift`() async {
|
|
let store = Self.makeStore()
|
|
let primaryReset = Date(timeIntervalSince1970: 1_710_000_000)
|
|
let secondaryReset = Date(timeIntervalSince1970: 1_710_086_400)
|
|
let snapshot = UsageSnapshot(
|
|
primary: RateWindow(
|
|
usedPercent: 18,
|
|
windowMinutes: 360,
|
|
resetsAt: primaryReset,
|
|
resetDescription: "6h"),
|
|
secondary: RateWindow(
|
|
usedPercent: 42,
|
|
windowMinutes: 11040,
|
|
resetsAt: secondaryReset,
|
|
resetDescription: "7.67d"),
|
|
updatedAt: Date(),
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .codex,
|
|
accountEmail: "alice@example.com",
|
|
accountOrganization: nil,
|
|
loginMethod: "plus"))
|
|
store._setSnapshotForTesting(snapshot, provider: .codex)
|
|
|
|
await store.recordPlanUtilizationHistorySample(
|
|
provider: .codex,
|
|
snapshot: snapshot,
|
|
now: Date(timeIntervalSince1970: 1_700_000_000))
|
|
|
|
let histories = store.planUtilizationHistory(for: .codex)
|
|
#expect(findSeries(histories, name: .session, windowMinutes: 360)?.entries.last?.usedPercent == 18)
|
|
#expect(findSeries(histories, name: .session, windowMinutes: 360)?.entries.last?.resetsAt == primaryReset)
|
|
#expect(findSeries(histories, name: .weekly, windowMinutes: 11040)?.entries.last?.usedPercent == 42)
|
|
#expect(findSeries(histories, name: .weekly, windowMinutes: 11040)?.entries.last?.resetsAt == secondaryReset)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `record plan history stores claude opus as separate series`() async {
|
|
let store = Self.makeStore()
|
|
let snapshot = UsageSnapshot(
|
|
primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
|
|
secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
|
|
tertiary: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
|
|
updatedAt: Date(),
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .claude,
|
|
accountEmail: "alice@example.com",
|
|
accountOrganization: nil,
|
|
loginMethod: "max"))
|
|
store._setSnapshotForTesting(snapshot, provider: .claude)
|
|
|
|
await store.recordPlanUtilizationHistorySample(
|
|
provider: .claude,
|
|
snapshot: snapshot,
|
|
now: Date(timeIntervalSince1970: 1_700_000_000))
|
|
|
|
let histories = store.planUtilizationHistory(for: .claude)
|
|
#expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10)
|
|
#expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20)
|
|
#expect(findSeries(histories, name: .opus, windowMinutes: 10080)?.entries.last?.usedPercent == 30)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `generic provider weekly lane is persisted to provider history json`() async throws {
|
|
let store = Self.makeStore()
|
|
store.settings.historicalTrackingEnabled = true
|
|
let accountLabel = "zai-history-org"
|
|
let firstDate = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let before = UsageSnapshot(
|
|
primary: RateWindow(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
|
|
secondary: RateWindow(usedPercent: 15, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
|
|
updatedAt: firstDate,
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .zai,
|
|
accountEmail: nil,
|
|
accountOrganization: accountLabel,
|
|
loginMethod: "pro"))
|
|
let after = UsageSnapshot(
|
|
primary: RateWindow(usedPercent: 58, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
|
|
secondary: RateWindow(usedPercent: 25, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
|
|
updatedAt: firstDate.addingTimeInterval(3600),
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: .zai,
|
|
accountEmail: nil,
|
|
accountOrganization: accountLabel,
|
|
loginMethod: "pro"))
|
|
|
|
await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt)
|
|
await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt)
|
|
|
|
let histories = store.planUtilizationHistory(for: .zai)
|
|
#expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.map(\.usedPercent) == [42, 58])
|
|
#expect(findSeries(histories, name: .session, windowMinutes: 300) == nil)
|
|
|
|
let providerURL = try #require(store.planUtilizationHistoryStore.directoryURL?
|
|
.appendingPathComponent("zai.json", isDirectory: false))
|
|
var persistedBuckets: PlanUtilizationHistoryBuckets?
|
|
for _ in 0..<20 {
|
|
persistedBuckets = store.planUtilizationHistoryStore.load()[.zai]
|
|
let weeklyEntries = persistedBuckets?.histories(for: persistedBuckets?.preferredAccountKey)
|
|
.first { $0.name == .weekly }?.entries.count
|
|
if weeklyEntries == 2 {
|
|
break
|
|
}
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
#expect(FileManager.default.fileExists(atPath: providerURL.path))
|
|
let persisted = try #require(persistedBuckets)
|
|
#expect(persisted.histories(for: persisted.preferredAccountKey)
|
|
.first { $0.name == .weekly }?.entries.map(\.usedPercent) == [42, 58])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `generic history opt in controls recording while saved history stays visible`() async throws {
|
|
let store = Self.makeStore()
|
|
let firstDate = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let before = UsageSnapshot(
|
|
primary: RateWindow(usedPercent: 42, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
|
|
secondary: nil,
|
|
updatedAt: firstDate)
|
|
store._setSnapshotForTesting(before, provider: .zai)
|
|
let providerURL = try #require(store.planUtilizationHistoryStore.directoryURL?
|
|
.appendingPathComponent("zai.json", isDirectory: false))
|
|
|
|
#expect(store.settings.historicalTrackingEnabled == false)
|
|
#expect(store.supportsPlanUtilizationHistory(for: .zai) == false)
|
|
await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt)
|
|
#expect(store.planUtilizationHistory(for: .zai).isEmpty)
|
|
#expect(FileManager.default.fileExists(atPath: providerURL.path) == false)
|
|
|
|
store.settings.historicalTrackingEnabled = true
|
|
#expect(store.supportsPlanUtilizationHistory(for: .zai))
|
|
await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: before, now: before.updatedAt)
|
|
#expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)?
|
|
.entries.map(\.usedPercent) == [42])
|
|
|
|
store.settings.historicalTrackingEnabled = false
|
|
#expect(store.supportsPlanUtilizationHistory(for: .zai))
|
|
let after = UsageSnapshot(
|
|
primary: RateWindow(usedPercent: 58, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
|
|
secondary: nil,
|
|
updatedAt: firstDate.addingTimeInterval(3600))
|
|
await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: after, now: after.updatedAt)
|
|
#expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)?
|
|
.entries.map(\.usedPercent) == [42])
|
|
|
|
for _ in 0..<20 where !FileManager.default.fileExists(atPath: providerURL.path) {
|
|
try await Task.sleep(nanoseconds: 50_000_000)
|
|
}
|
|
#expect(FileManager.default.fileExists(atPath: providerURL.path))
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `generic provider persists weekly extra window`() async {
|
|
let store = Self.makeStore()
|
|
store.settings.historicalTrackingEnabled = true
|
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let snapshot = UsageSnapshot(
|
|
primary: nil,
|
|
secondary: nil,
|
|
extraRateWindows: [
|
|
NamedRateWindow(
|
|
id: "weekly-budget",
|
|
title: "Weekly budget",
|
|
window: RateWindow(
|
|
usedPercent: 42,
|
|
windowMinutes: 10080,
|
|
resetsAt: nil,
|
|
resetDescription: nil)),
|
|
],
|
|
updatedAt: now)
|
|
|
|
await store.recordPlanUtilizationHistorySample(provider: .zai, snapshot: snapshot, now: now)
|
|
|
|
#expect(findSeries(store.planUtilizationHistory(for: .zai), name: .weekly, windowMinutes: 10080)?
|
|
.entries.map(\.usedPercent) == [42])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `generic provider ignores unknown weekly extra window`() async {
|
|
let store = Self.makeStore()
|
|
store.settings.historicalTrackingEnabled = true
|
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let snapshot = UsageSnapshot(
|
|
primary: nil,
|
|
secondary: nil,
|
|
extraRateWindows: [
|
|
NamedRateWindow(
|
|
id: "weekly-reset-only",
|
|
title: "Weekly reset",
|
|
window: RateWindow(
|
|
usedPercent: 0,
|
|
windowMinutes: 10080,
|
|
resetsAt: now.addingTimeInterval(3600),
|
|
resetDescription: nil),
|
|
usageKnown: false),
|
|
],
|
|
updatedAt: now)
|
|
|
|
await store.recordPlanUtilizationHistorySample(provider: .zed, snapshot: snapshot, now: now)
|
|
|
|
#expect(store.planUtilizationHistory(for: .zed).isEmpty)
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `generic provider prefers standard weekly window over extra window`() async {
|
|
let store = Self.makeStore()
|
|
store.settings.historicalTrackingEnabled = true
|
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let snapshot = UsageSnapshot(
|
|
primary: nil,
|
|
secondary: RateWindow(
|
|
usedPercent: 42,
|
|
windowMinutes: 10080,
|
|
resetsAt: nil,
|
|
resetDescription: nil),
|
|
extraRateWindows: [
|
|
NamedRateWindow(
|
|
id: "extra-weekly-budget",
|
|
title: "Extra weekly budget",
|
|
window: RateWindow(
|
|
usedPercent: 84,
|
|
windowMinutes: 10080,
|
|
resetsAt: nil,
|
|
resetDescription: nil)),
|
|
],
|
|
updatedAt: now)
|
|
|
|
await store.recordPlanUtilizationHistorySample(provider: .factory, snapshot: snapshot, now: now)
|
|
|
|
#expect(findSeries(store.planUtilizationHistory(for: .factory), name: .weekly, windowMinutes: 10080)?
|
|
.entries.map(\.usedPercent) == [42])
|
|
}
|
|
|
|
@MainActor
|
|
@Test
|
|
func `concurrent plan history writes coalesce within single hour bucket per series`() async throws {
|
|
let store = Self.makeStore()
|
|
let snapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com")
|
|
store._setSnapshotForTesting(snapshot, provider: .codex)
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let hourStart = try #require(calendar.date(from: DateComponents(
|
|
timeZone: TimeZone(secondsFromGMT: 0),
|
|
year: 2026,
|
|
month: 3,
|
|
day: 17,
|
|
hour: 10)))
|
|
let writeTimes = [
|
|
hourStart.addingTimeInterval(5 * 60),
|
|
hourStart.addingTimeInterval(25 * 60),
|
|
hourStart.addingTimeInterval(45 * 60),
|
|
]
|
|
|
|
await withTaskGroup(of: Void.self) { group in
|
|
for writeTime in writeTimes {
|
|
group.addTask {
|
|
await store.recordPlanUtilizationHistorySample(
|
|
provider: .codex,
|
|
snapshot: snapshot,
|
|
now: writeTime)
|
|
}
|
|
}
|
|
}
|
|
|
|
let histories = try #require(store.planUtilizationHistory[.codex]?.accounts.values.first)
|
|
#expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.count == 1)
|
|
#expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.count == 1)
|
|
}
|
|
|
|
@Test
|
|
func `runtime does not load unsupported plan history file`() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let directoryURL = root
|
|
.appendingPathComponent("com.steipete.codexbar", isDirectory: true)
|
|
.appendingPathComponent("history", isDirectory: true)
|
|
let providerURL = directoryURL.appendingPathComponent("codex.json")
|
|
let store = PlanUtilizationHistoryStore(directoryURL: directoryURL)
|
|
try FileManager.default.createDirectory(
|
|
at: directoryURL,
|
|
withIntermediateDirectories: true)
|
|
|
|
let unsupportedJSON = """
|
|
{
|
|
"version": 999,
|
|
"unscoped": [],
|
|
"accounts": {}
|
|
}
|
|
"""
|
|
try Data(unsupportedJSON.utf8).write(to: providerURL, options: Data.WritingOptions.atomic)
|
|
|
|
let loaded = store.load()
|
|
#expect(loaded.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func `store drops invalid zero minute and empty histories when loading and saving`() throws {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let directoryURL = root
|
|
.appendingPathComponent("com.steipete.codexbar", isDirectory: true)
|
|
.appendingPathComponent("history", isDirectory: true)
|
|
let providerURL = directoryURL.appendingPathComponent("codex.json")
|
|
let store = PlanUtilizationHistoryStore(directoryURL: directoryURL)
|
|
try FileManager.default.createDirectory(
|
|
at: directoryURL,
|
|
withIntermediateDirectories: true)
|
|
|
|
let validUnscoped = planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 12),
|
|
])
|
|
let validAccount = planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 64),
|
|
])
|
|
let document = PersistedFixtureDocument(
|
|
version: 1,
|
|
preferredAccountKey: "alice",
|
|
unscoped: [
|
|
planSeries(name: .session, windowMinutes: 0, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 99),
|
|
]),
|
|
planSeries(name: .weekly, windowMinutes: 10080, entries: []),
|
|
validUnscoped,
|
|
],
|
|
accounts: [
|
|
"alice": [
|
|
planSeries(name: .session, windowMinutes: 0, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 88),
|
|
]),
|
|
validAccount,
|
|
],
|
|
"empty": [
|
|
planSeries(name: .weekly, windowMinutes: 10080, entries: []),
|
|
],
|
|
])
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
try encoder.encode(document).write(to: providerURL, options: Data.WritingOptions.atomic)
|
|
|
|
let loaded = store.load()
|
|
let loadedBuckets = try #require(loaded[.codex])
|
|
#expect(loadedBuckets.unscoped == [validUnscoped])
|
|
#expect(loadedBuckets.accounts == ["alice": [validAccount]])
|
|
|
|
store.save(loaded)
|
|
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
let rewritten = try decoder.decode(PersistedFixtureDocument.self, from: Data(contentsOf: providerURL))
|
|
#expect(rewritten.unscoped == [validUnscoped])
|
|
#expect(rewritten.accounts == ["alice": [validAccount]])
|
|
}
|
|
|
|
@Test
|
|
func `store round trips account buckets with series entries`() {
|
|
let root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
let directoryURL = root
|
|
.appendingPathComponent("com.steipete.codexbar", isDirectory: true)
|
|
.appendingPathComponent("history", isDirectory: true)
|
|
let store = PlanUtilizationHistoryStore(directoryURL: directoryURL)
|
|
let buckets = PlanUtilizationHistoryBuckets(
|
|
preferredAccountKey: "alice",
|
|
unscoped: [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_699_913_600), usedPercent: 50),
|
|
]),
|
|
],
|
|
accounts: [
|
|
"alice": [
|
|
planSeries(name: .session, windowMinutes: 300, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 10),
|
|
]),
|
|
planSeries(name: .weekly, windowMinutes: 10080, entries: [
|
|
planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20),
|
|
]),
|
|
],
|
|
])
|
|
|
|
store.save([.codex: buckets])
|
|
let loaded = store.load()
|
|
|
|
#expect(loaded == [.codex: buckets])
|
|
}
|
|
}
|
|
|
|
extension UsageStorePlanUtilizationTests {
|
|
private struct PersistedFixtureDocument: Codable {
|
|
let version: Int
|
|
let preferredAccountKey: String?
|
|
let unscoped: [PlanUtilizationSeriesHistory]
|
|
let accounts: [String: [PlanUtilizationSeriesHistory]]
|
|
}
|
|
|
|
private struct FixtureDocument: Decodable {
|
|
let preferredAccountKey: String?
|
|
let unscoped: [PlanUtilizationSeriesHistory]
|
|
let accounts: [String: [PlanUtilizationSeriesHistory]]
|
|
}
|
|
|
|
@MainActor
|
|
static func makeStore() -> UsageStore {
|
|
let suiteName = "UsageStorePlanUtilizationTests-\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suiteName) else {
|
|
fatalError("Failed to create isolated UserDefaults suite for tests")
|
|
}
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
let configStore = testConfigStore(suiteName: suiteName)
|
|
let planHistoryStore = testPlanUtilizationHistoryStore(suiteName: suiteName)
|
|
let temporaryRoot = FileManager.default.temporaryDirectory.standardizedFileURL.path
|
|
let managedStoreURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("\(suiteName)-managed-codex-accounts.json")
|
|
precondition(configStore.fileURL.standardizedFileURL.path.hasPrefix(temporaryRoot))
|
|
precondition(configStore.fileURL.standardizedFileURL != CodexBarConfigStore.defaultURL().standardizedFileURL)
|
|
if let historyURL = planHistoryStore.directoryURL?.standardizedFileURL {
|
|
precondition(historyURL.path.hasPrefix(temporaryRoot))
|
|
}
|
|
let managedStore = FileManagedCodexAccountStore(fileURL: managedStoreURL)
|
|
try? FileManager.default.removeItem(at: managedStoreURL)
|
|
do {
|
|
try managedStore.storeAccounts(ManagedCodexAccountSet(
|
|
version: FileManagedCodexAccountStore.currentVersion,
|
|
accounts: []))
|
|
} catch {
|
|
fatalError("Failed to seed isolated managed Codex account store: \(error)")
|
|
}
|
|
let isolatedSettings = SettingsStore(
|
|
userDefaults: defaults,
|
|
configStore: configStore,
|
|
tokenAccountStore: InMemoryTokenAccountStore())
|
|
let store = UsageStore(
|
|
fetcher: UsageFetcher(),
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
settings: isolatedSettings,
|
|
planUtilizationHistoryStore: planHistoryStore,
|
|
startupBehavior: .testing)
|
|
isolatedSettings._test_managedCodexAccountStoreURL = managedStoreURL
|
|
isolatedSettings.codexActiveSource = .liveSystem
|
|
// Cancel the background plan-utilization decode so it cannot race the
|
|
// explicit empty assignment below. Production paths still load on the
|
|
// utility queue; this only short-circuits the test setup.
|
|
store._cancelPlanUtilizationHistoryLoadForTesting()
|
|
store.planUtilizationHistory = [:]
|
|
return store
|
|
}
|
|
|
|
static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot {
|
|
UsageSnapshot(
|
|
primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
|
|
secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil),
|
|
updatedAt: Date(),
|
|
identity: ProviderIdentitySnapshot(
|
|
providerID: provider,
|
|
accountEmail: email,
|
|
accountOrganization: nil,
|
|
loginMethod: "plus"))
|
|
}
|
|
|
|
static func loadPlanUtilizationFixture(named name: String) throws -> PlanUtilizationHistoryBuckets {
|
|
let fixtureURL = URL(fileURLWithPath: #filePath)
|
|
.deletingLastPathComponent()
|
|
.appendingPathComponent("Fixtures", isDirectory: true)
|
|
.appendingPathComponent(name, isDirectory: false)
|
|
let data = try Data(contentsOf: fixtureURL)
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
let document = try decoder.decode(FixtureDocument.self, from: data)
|
|
return PlanUtilizationHistoryBuckets(
|
|
preferredAccountKey: document.preferredAccountKey,
|
|
unscoped: document.unscoped,
|
|
accounts: document.accounts)
|
|
}
|
|
}
|
|
|
|
extension UsageStorePlanUtilizationTests {
|
|
@MainActor
|
|
@Test
|
|
func `global refresh tail does not keep completed provider plan card loading`() {
|
|
let store = Self.makeStore()
|
|
store._setSnapshotForTesting(nil, provider: .claude)
|
|
store.isRefreshing = true
|
|
store.refreshingProviders.insert(.claude)
|
|
|
|
#expect(store.shouldShowRefreshingMenuCard(for: .claude))
|
|
#expect(store.shouldShowRefreshingMenuCardIndicator(for: .claude))
|
|
#expect(store.shouldHidePlanUtilizationMenuItem(for: .claude))
|
|
|
|
store.refreshingProviders.remove(.claude)
|
|
|
|
#expect(store.isRefreshing)
|
|
#expect(store.refreshingProviders.isEmpty)
|
|
#expect(!store.shouldShowRefreshingMenuCard(for: .claude))
|
|
#expect(!store.shouldShowRefreshingMenuCardIndicator(for: .claude))
|
|
#expect(!store.shouldHidePlanUtilizationMenuItem(for: .claude))
|
|
}
|
|
}
|
|
|
|
func planEntry(at capturedAt: Date, usedPercent: Double, resetsAt: Date? = nil) -> PlanUtilizationHistoryEntry {
|
|
PlanUtilizationHistoryEntry(capturedAt: capturedAt, usedPercent: usedPercent, resetsAt: resetsAt)
|
|
}
|
|
|
|
func planSeries(
|
|
name: PlanUtilizationSeriesName,
|
|
windowMinutes: Int,
|
|
entries: [PlanUtilizationHistoryEntry]) -> PlanUtilizationSeriesHistory
|
|
{
|
|
PlanUtilizationSeriesHistory(name: name, windowMinutes: windowMinutes, entries: entries)
|
|
}
|
|
|
|
func findSeries(
|
|
_ histories: [PlanUtilizationSeriesHistory],
|
|
name: PlanUtilizationSeriesName,
|
|
windowMinutes: Int) -> PlanUtilizationSeriesHistory?
|
|
{
|
|
histories.first { $0.name == name && $0.windowMinutes == windowMinutes }
|
|
}
|
|
|
|
func formattedBoundary(_ date: Date) -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = TimeZone.current
|
|
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
|
return formatter.string(from: date)
|
|
}
|