275 lines
11 KiB
Swift
275 lines
11 KiB
Swift
import AppKit
|
|
import CodexBarCore
|
|
import Testing
|
|
@testable import CodexBar
|
|
|
|
@MainActor
|
|
@Suite(.serialized)
|
|
struct StatusMenuCodexCostHistoryRefreshTests {
|
|
@Test
|
|
func `codex cost history preserves identity when hidden project nested data changes`() throws {
|
|
try self.assertCodexCostHistoryPreservesIdentity(
|
|
mutate: { snapshot in
|
|
var projects = snapshot.projects
|
|
guard projects.count > 5 else { return snapshot }
|
|
projects[5] = Self.makeCodexProject(
|
|
index: 5,
|
|
sourceCount: 1,
|
|
nestedDailyCost: 99.0)
|
|
return Self.copySnapshot(snapshot, projects: projects)
|
|
})
|
|
}
|
|
|
|
@Test
|
|
func `codex cost history preserves identity when visible project nested data changes`() throws {
|
|
try self.assertCodexCostHistoryPreservesIdentity(
|
|
mutate: { snapshot in
|
|
var projects = snapshot.projects
|
|
guard !projects.isEmpty else { return snapshot }
|
|
projects[0] = Self.makeCodexProject(
|
|
index: 0,
|
|
sourceCount: 1,
|
|
nestedDailyCost: 99.0)
|
|
return Self.copySnapshot(snapshot, projects: projects)
|
|
})
|
|
}
|
|
|
|
@Test
|
|
func `codex cost history rebuilds when daily cost changes`() throws {
|
|
try self.assertCodexCostHistoryRebuilds(
|
|
mutate: { snapshot in
|
|
Self.copySnapshot(snapshot, dailyCost: 9.87)
|
|
})
|
|
}
|
|
|
|
@Test
|
|
func `hydrated codex cost history stores the same fingerprint as refresh`() throws {
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering }
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.costUsageEnabled = true
|
|
Self.enableOnly(settings, provider: .codex)
|
|
|
|
let fetcher = UsageFetcher()
|
|
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
|
|
store._setTokenSnapshotForTesting(Self.makeCodexCostSnapshot(), provider: .codex)
|
|
|
|
let controller = StatusItemController(
|
|
store: store,
|
|
settings: settings,
|
|
account: fetcher.loadAccountInfo(),
|
|
updater: DisabledUpdaterController(),
|
|
preferencesSelection: PreferencesSelection(),
|
|
statusBar: .system)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let width = StatusItemController.menuCardBaseWidth
|
|
let submenu = controller.makeHostedSubviewPlaceholderMenu(
|
|
chartID: StatusItemController.costHistoryChartID,
|
|
provider: .codex,
|
|
width: width)
|
|
controller.menuWillOpen(submenu)
|
|
|
|
let stored = try #require(controller._storedHostedSubviewRenderSignatureForTesting(menu: submenu))
|
|
let recomputed = try #require(controller._hostedSubviewRenderSignatureForTesting(menu: submenu, width: width))
|
|
#expect(stored == recomputed)
|
|
|
|
controller.refreshHostedSubviewMenu(submenu)
|
|
#expect(controller._storedHostedSubviewRenderSignatureForTesting(menu: submenu) == recomputed)
|
|
}
|
|
|
|
private func assertCodexCostHistoryPreservesIdentity(
|
|
mutate: (CostUsageTokenSnapshot) -> CostUsageTokenSnapshot) throws
|
|
{
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering }
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.costUsageEnabled = true
|
|
Self.enableOnly(settings, provider: .codex)
|
|
|
|
let fetcher = UsageFetcher()
|
|
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
|
|
store._setTokenSnapshotForTesting(Self.makeCodexCostSnapshot(), provider: .codex)
|
|
|
|
let controller = StatusItemController(
|
|
store: store,
|
|
settings: settings,
|
|
account: fetcher.loadAccountInfo(),
|
|
updater: DisabledUpdaterController(),
|
|
preferencesSelection: PreferencesSelection(),
|
|
statusBar: .system)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let submenu = controller.makeHostedSubviewPlaceholderMenu(
|
|
chartID: StatusItemController.costHistoryChartID,
|
|
provider: .codex,
|
|
width: StatusItemController.menuCardBaseWidth)
|
|
controller.menuWillOpen(submenu)
|
|
|
|
let hydratedView = try #require(submenu.items.first?.view)
|
|
store._setTokenSnapshotForTesting(mutate(Self.makeCodexCostSnapshot()), provider: .codex)
|
|
controller.refreshHostedSubviewMenu(submenu)
|
|
|
|
#expect(submenu.items.first?.view === hydratedView)
|
|
}
|
|
|
|
private func assertCodexCostHistoryRebuilds(
|
|
mutate: (CostUsageTokenSnapshot) -> CostUsageTokenSnapshot) throws
|
|
{
|
|
let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled
|
|
StatusItemController.menuCardRenderingEnabled = true
|
|
defer { StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering }
|
|
|
|
let settings = Self.makeSettings()
|
|
settings.costUsageEnabled = true
|
|
Self.enableOnly(settings, provider: .codex)
|
|
|
|
let fetcher = UsageFetcher()
|
|
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
|
|
store._setTokenSnapshotForTesting(Self.makeCodexCostSnapshot(), provider: .codex)
|
|
|
|
let controller = StatusItemController(
|
|
store: store,
|
|
settings: settings,
|
|
account: fetcher.loadAccountInfo(),
|
|
updater: DisabledUpdaterController(),
|
|
preferencesSelection: PreferencesSelection(),
|
|
statusBar: .system)
|
|
defer { controller.releaseStatusItemsForTesting() }
|
|
|
|
let submenu = controller.makeHostedSubviewPlaceholderMenu(
|
|
chartID: StatusItemController.costHistoryChartID,
|
|
provider: .codex,
|
|
width: StatusItemController.menuCardBaseWidth)
|
|
controller.menuWillOpen(submenu)
|
|
|
|
let hydratedView = try #require(submenu.items.first?.view)
|
|
store._setTokenSnapshotForTesting(mutate(Self.makeCodexCostSnapshot()), provider: .codex)
|
|
controller.refreshHostedSubviewMenu(submenu)
|
|
|
|
#expect(submenu.items.first?.view !== hydratedView)
|
|
}
|
|
|
|
private static func makeSettings() -> SettingsStore {
|
|
let suite = "StatusMenuCodexCostHistoryRefreshTests-\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defaults.removePersistentDomain(forName: suite)
|
|
return SettingsStore(
|
|
userDefaults: defaults,
|
|
configStore: testConfigStore(suiteName: suite),
|
|
zaiTokenStore: NoopZaiTokenStore(),
|
|
syntheticTokenStore: NoopSyntheticTokenStore())
|
|
}
|
|
|
|
private static func enableOnly(_ settings: SettingsStore, provider enabledProvider: UsageProvider) {
|
|
let registry = ProviderRegistry.shared
|
|
for provider in UsageProvider.allCases {
|
|
guard let metadata = registry.metadata[provider] else { continue }
|
|
settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider)
|
|
}
|
|
}
|
|
|
|
private static func makeCodexCostSnapshot(
|
|
dailyCost: Double = 1.23,
|
|
projectCount: Int = 6) -> CostUsageTokenSnapshot
|
|
{
|
|
CostUsageTokenSnapshot(
|
|
sessionTokens: 123,
|
|
sessionCostUSD: 0.12,
|
|
last30DaysTokens: 123,
|
|
last30DaysCostUSD: dailyCost,
|
|
daily: [
|
|
CostUsageDailyReport.Entry(
|
|
date: "2025-12-23",
|
|
inputTokens: nil,
|
|
outputTokens: nil,
|
|
totalTokens: 123,
|
|
costUSD: dailyCost,
|
|
modelsUsed: nil,
|
|
modelBreakdowns: nil),
|
|
],
|
|
projects: (0..<projectCount).map { Self.makeCodexProject(index: $0, sourceCount: 1) },
|
|
updatedAt: Date())
|
|
}
|
|
|
|
private static func copySnapshot(
|
|
_ snapshot: CostUsageTokenSnapshot,
|
|
dailyCost: Double? = nil,
|
|
projects: [CostUsageProjectBreakdown]? = nil) -> CostUsageTokenSnapshot
|
|
{
|
|
let daily = snapshot.daily.map { entry in
|
|
CostUsageDailyReport.Entry(
|
|
date: entry.date,
|
|
inputTokens: entry.inputTokens,
|
|
outputTokens: entry.outputTokens,
|
|
totalTokens: entry.totalTokens,
|
|
costUSD: dailyCost ?? entry.costUSD,
|
|
modelsUsed: entry.modelsUsed,
|
|
modelBreakdowns: entry.modelBreakdowns)
|
|
}
|
|
return CostUsageTokenSnapshot(
|
|
sessionTokens: snapshot.sessionTokens,
|
|
sessionCostUSD: snapshot.sessionCostUSD,
|
|
last30DaysTokens: snapshot.last30DaysTokens,
|
|
last30DaysCostUSD: dailyCost ?? snapshot.last30DaysCostUSD,
|
|
currencyCode: snapshot.currencyCode,
|
|
historyDays: snapshot.historyDays,
|
|
historyLabel: snapshot.historyLabel,
|
|
daily: daily,
|
|
projects: projects ?? snapshot.projects,
|
|
updatedAt: snapshot.updatedAt)
|
|
}
|
|
|
|
private static func makeCodexProject(
|
|
index: Int,
|
|
sourceCount: Int,
|
|
nestedDailyCost: Double = 0.01) -> CostUsageProjectBreakdown
|
|
{
|
|
let nestedDaily = [
|
|
CostUsageDailyReport.Entry(
|
|
date: "2025-12-23",
|
|
inputTokens: 1,
|
|
outputTokens: 1,
|
|
totalTokens: 10,
|
|
costUSD: nestedDailyCost,
|
|
modelsUsed: ["nested"],
|
|
modelBreakdowns: [
|
|
CostUsageDailyReport.ModelBreakdown(
|
|
modelName: "nested-model",
|
|
costUSD: nestedDailyCost,
|
|
totalTokens: 10),
|
|
]),
|
|
]
|
|
return CostUsageProjectBreakdown(
|
|
name: "Project-\(index)",
|
|
path: "/tmp/project-\(index)",
|
|
totalTokens: 100 + index,
|
|
totalCostUSD: 1.0 + Double(index),
|
|
daily: nestedDaily,
|
|
modelBreakdowns: [
|
|
CostUsageDailyReport.ModelBreakdown(
|
|
modelName: "project-model",
|
|
costUSD: nestedDailyCost,
|
|
totalTokens: 10),
|
|
],
|
|
sources: (0..<sourceCount).map { sourceIndex in
|
|
CostUsageProjectSourceBreakdown(
|
|
name: "Source-\(sourceIndex)",
|
|
path: "/tmp/project-\(index)/source-\(sourceIndex)",
|
|
totalTokens: 10 + sourceIndex,
|
|
totalCostUSD: 0.5,
|
|
daily: nestedDaily,
|
|
modelBreakdowns: [
|
|
CostUsageDailyReport.ModelBreakdown(
|
|
modelName: "source-model",
|
|
costUSD: nestedDailyCost,
|
|
totalTokens: 10),
|
|
])
|
|
})
|
|
}
|
|
}
|