diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 34ac51846..13bcc7927 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -29,6 +29,7 @@ struct UsageMenuCardView: View { let title: String let percent: Double let percentStyle: PercentStyle + let statusText: String? let resetText: String? let detailText: String? let detailLeftText: String? @@ -36,6 +37,32 @@ struct UsageMenuCardView: View { let pacePercent: Double? let paceOnTop: Bool + init( + id: String, + title: String, + percent: Double, + percentStyle: PercentStyle, + statusText: String? = nil, + resetText: String?, + detailText: String?, + detailLeftText: String?, + detailRightText: String?, + pacePercent: Double?, + paceOnTop: Bool) + { + self.id = id + self.title = title + self.percent = percent + self.percentStyle = percentStyle + self.statusText = statusText + self.resetText = resetText + self.detailText = detailText + self.detailLeftText = detailLeftText + self.detailRightText = detailRightText + self.pacePercent = pacePercent + self.paceOnTop = paceOnTop + } + var percentLabel: String { String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix) } @@ -330,49 +357,56 @@ private struct MetricRow: View { Text(self.title) .font(.body) .fontWeight(.medium) - UsageProgressBar( - percent: self.metric.percent, - tint: self.progressColor, - accessibilityLabel: self.metric.percentStyle.accessibilityLabel, - pacePercent: self.metric.pacePercent, - paceOnTop: self.metric.paceOnTop) - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline) { - Text(self.metric.percentLabel) - .font(.footnote) - .lineLimit(1) - Spacer() - if let rightLabel = self.metric.resetText { - Text(rightLabel) + if let statusText = self.metric.statusText { + Text(statusText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } else { + UsageProgressBar( + percent: self.metric.percent, + tint: self.progressColor, + accessibilityLabel: self.metric.percentStyle.accessibilityLabel, + pacePercent: self.metric.pacePercent, + paceOnTop: self.metric.paceOnTop) + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline) { + Text(self.metric.percentLabel) .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) - } - } - if self.metric.detailLeftText != nil || self.metric.detailRightText != nil { - HStack(alignment: .firstTextBaseline) { - if let detailLeft = self.metric.detailLeftText { - Text(detailLeft) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) - .lineLimit(1) - } Spacer() - if let detailRight = self.metric.detailRightText { - Text(detailRight) + if let rightLabel = self.metric.resetText { + Text(rightLabel) .font(.footnote) .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) } } + if self.metric.detailLeftText != nil || self.metric.detailRightText != nil { + HStack(alignment: .firstTextBaseline) { + if let detailLeft = self.metric.detailLeftText { + Text(detailLeft) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.primary(self.isHighlighted)) + .lineLimit(1) + } + Spacer() + if let detailRight = self.metric.detailRightText { + Text(detailRight) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + if let detail = self.metric.detailText { + Text(detail) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) } - } - .frame(maxWidth: .infinity, alignment: .leading) - if let detail = self.metric.detailText { - Text(detail) - .font(.footnote) - .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) - .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -898,6 +932,9 @@ extension UsageMenuCardView.Model { private static func metrics(input: Input) -> [Metric] { guard let snapshot = input.snapshot else { return [] } + if input.provider == .antigravity { + return Self.antigravityMetrics(input: input, snapshot: snapshot) + } var metrics: [Metric] = [] let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left let zaiUsage = input.provider == .zai ? snapshot.zaiUsage : nil @@ -1013,6 +1050,66 @@ extension UsageMenuCardView.Model { return metrics } + private static func antigravityMetrics(input: Input, snapshot: UsageSnapshot) -> [Metric] { + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left + return [ + Self.antigravityMetric( + id: "primary", + title: input.metadata.sessionLabel, + window: snapshot.primary, + input: input, + percentStyle: percentStyle), + Self.antigravityMetric( + id: "secondary", + title: input.metadata.weeklyLabel, + window: snapshot.secondary, + input: input, + percentStyle: percentStyle), + Self.antigravityMetric( + id: "tertiary", + title: input.metadata.opusLabel ?? "Gemini Flash", + window: snapshot.tertiary, + input: input, + percentStyle: percentStyle), + ] + } + + private static func antigravityMetric( + id: String, + title: String, + window: RateWindow?, + input: Input, + percentStyle: PercentStyle) -> Metric + { + guard let window else { + let placeholderPercent = input.usageBarsShowUsed ? 100.0 : 0.0 + return Metric( + id: id, + title: title, + percent: placeholderPercent, + percentStyle: percentStyle, + statusText: nil, + resetText: nil, + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + } + let percent = input.usageBarsShowUsed ? window.usedPercent : window.remainingPercent + return Metric( + id: id, + title: title, + percent: Self.clamped(percent), + percentStyle: percentStyle, + resetText: Self.resetText(for: window, style: input.resetTimeDisplayStyle, now: input.now), + detailText: nil, + detailLeftText: nil, + detailRightText: nil, + pacePercent: nil, + paceOnTop: true) + } + private static func zaiLimitDetailText(limit: ZaiLimitEntry?) -> String? { guard let limit else { return nil } diff --git a/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift b/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift index 88492f070..fcac10ed1 100644 --- a/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Antigravity/AntigravityProviderImplementation.swift @@ -10,6 +10,9 @@ struct AntigravityProviderImplementation: ProviderImplementation { await AntigravityStatusProbe.detectVersion() } + @MainActor + func appendUsageMenuEntries(context _: ProviderMenuUsageContext, entries _: inout [ProviderMenuEntry]) {} + @MainActor func runLoginFlow(context: ProviderLoginContext) async -> Bool { await context.controller.runAntigravityLoginFlow() diff --git a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift index 444ae97b6..06726444f 100644 --- a/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Antigravity/AntigravityStatusProbe.swift @@ -10,26 +10,67 @@ public struct AntigravityModelQuota: Sendable { public let resetTime: Date? public let resetDescription: String? + public init( + label: String, + modelId: String, + remainingFraction: Double?, + resetTime: Date?, + resetDescription: String?) + { + self.label = label + self.modelId = modelId + self.remainingFraction = remainingFraction + self.resetTime = resetTime + self.resetDescription = resetDescription + } + public var remainingPercent: Double { guard let remainingFraction else { return 0 } return max(0, min(100, remainingFraction * 100)) } } +private enum AntigravityModelFamily: Sendable { + case claude + case geminiPro + case geminiFlash + case unknown +} + +private struct AntigravityNormalizedModel: Sendable { + let quota: AntigravityModelQuota + let family: AntigravityModelFamily + let selectionPriority: Int? +} + public struct AntigravityStatusSnapshot: Sendable { public let modelQuotas: [AntigravityModelQuota] public let accountEmail: String? public let accountPlan: String? + public init( + modelQuotas: [AntigravityModelQuota], + accountEmail: String?, + accountPlan: String?) + { + self.modelQuotas = modelQuotas + self.accountEmail = accountEmail + self.accountPlan = accountPlan + } + public func toUsageSnapshot() throws -> UsageSnapshot { - let ordered = Self.selectModels(self.modelQuotas) - guard let primaryQuota = ordered.first else { + guard !self.modelQuotas.isEmpty else { throw AntigravityStatusProbeError.parseFailed("No quota models available") } - let primary = Self.rateWindow(for: primaryQuota) - let secondary = ordered.count > 1 ? Self.rateWindow(for: ordered[1]) : nil - let tertiary = ordered.count > 2 ? Self.rateWindow(for: ordered[2]) : nil + let normalized = Self.normalizedModels(self.modelQuotas) + let primaryQuota = Self.representative(for: .claude, in: normalized) + let secondaryQuota = Self.representative(for: .geminiPro, in: normalized) + let tertiaryQuota = Self.representative(for: .geminiFlash, in: normalized) + + let primary = primaryQuota.map(Self.rateWindow(for:)) + let secondary = secondaryQuota.map(Self.rateWindow(for:)) + let tertiary = tertiaryQuota.map(Self.rateWindow(for:)) let identity = ProviderIdentitySnapshot( providerID: .antigravity, @@ -52,40 +93,84 @@ public struct AntigravityStatusSnapshot: Sendable { resetDescription: quota.resetDescription) } - private static func selectModels(_ models: [AntigravityModelQuota]) -> [AntigravityModelQuota] { - var ordered: [AntigravityModelQuota] = [] - if let claude = models.first(where: { Self.isClaudeWithoutThinking($0.label) }) { - ordered.append(claude) - } - if let pro = models.first(where: { Self.isGeminiProLow($0.label) }), - !ordered.contains(where: { $0.label == pro.label }) - { - ordered.append(pro) - } - if let flash = models.first(where: { Self.isGeminiFlash($0.label) }), - !ordered.contains(where: { $0.label == flash.label }) - { - ordered.append(flash) - } - if ordered.isEmpty { - ordered.append(contentsOf: models.sorted(by: { $0.remainingPercent < $1.remainingPercent })) - } - return ordered + private static func normalizedModels(_ models: [AntigravityModelQuota]) -> [AntigravityNormalizedModel] { + models.map { self.normalizeModel($0) } } - private static func isClaudeWithoutThinking(_ label: String) -> Bool { - let lower = label.lowercased() - return lower.contains("claude") && !lower.contains("thinking") - } + private static func normalizeModel(_ quota: AntigravityModelQuota) -> AntigravityNormalizedModel { + let modelId = quota.modelId.lowercased() + let label = quota.label.lowercased() + let family = Self.family(forModelID: modelId, label: label) + + let isLite = modelId.contains("lite") || label.contains("lite") + let isAutocomplete = modelId.contains("autocomplete") || label.contains("autocomplete") || modelId + .hasPrefix("tab_") + let isLowPriorityGeminiPro = modelId.contains("pro-low") + || (label.contains("pro") && label.contains("low")) - private static func isGeminiProLow(_ label: String) -> Bool { - let lower = label.lowercased() - return lower.contains("pro") && lower.contains("low") + let selectionPriority: Int? = switch family { + case .claude: + 0 + case .geminiPro: + if isLowPriorityGeminiPro { + 0 + } else if !isLite, !isAutocomplete { + 1 + } else { + nil + } + case .geminiFlash: + (!isLite && !isAutocomplete) ? 0 : nil + case .unknown: + nil + } + + return AntigravityNormalizedModel( + quota: quota, + family: family, + selectionPriority: selectionPriority) } - private static func isGeminiFlash(_ label: String) -> Bool { - let lower = label.lowercased() - return lower.contains("gemini") && lower.contains("flash") + private static func representative( + for family: AntigravityModelFamily, + in models: [AntigravityNormalizedModel]) -> AntigravityModelQuota? + { + let candidates = models.filter { $0.family == family && $0.selectionPriority != nil } + guard !candidates.isEmpty else { return nil } + return candidates.min { lhs, rhs in + let lhsPriority = lhs.selectionPriority ?? Int.max + let rhsPriority = rhs.selectionPriority ?? Int.max + if lhsPriority != rhsPriority { + return lhsPriority < rhsPriority + } + let lhsHasRemainingFraction = lhs.quota.remainingFraction != nil + let rhsHasRemainingFraction = rhs.quota.remainingFraction != nil + if lhsHasRemainingFraction != rhsHasRemainingFraction { + return lhsHasRemainingFraction && !rhsHasRemainingFraction + } + return lhs.quota.remainingPercent < rhs.quota.remainingPercent + }?.quota + } + + private static func family(forModelID modelId: String, label: String) -> AntigravityModelFamily { + let modelIDFamily = Self.family(from: modelId) + if modelIDFamily != .unknown { + return modelIDFamily + } + return Self.family(from: label) + } + + private static func family(from text: String) -> AntigravityModelFamily { + if text.contains("claude") { + return .claude + } + if text.contains("gemini"), text.contains("pro") { + return .geminiPro + } + if text.contains("gemini"), text.contains("flash") { + return .geminiFlash + } + return .unknown } } diff --git a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift index 3f38b6b16..d165e6ca7 100644 --- a/Tests/CodexBarTests/AntigravityStatusProbeTests.swift +++ b/Tests/CodexBarTests/AntigravityStatusProbeTests.swift @@ -52,4 +52,244 @@ struct AntigravityStatusProbeTests { #expect(usage.secondary?.remainingPercent.rounded() == 80) #expect(usage.tertiary?.remainingPercent.rounded() == 20) } + + @Test + func `claude bar can use thinking variants`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Thinking", + modelId: "claude-thinking", + remainingFraction: 0.7, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet 4", + modelId: "claude-sonnet-4", + remainingFraction: 0.3, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.remainingPercent.rounded() == 30) + } + + @Test + func `claude bar uses thinking model when it is the only claude option`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Thinking", + modelId: "claude-thinking", + remainingFraction: 0.7, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro Low", + modelId: "gemini-3-pro-low", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.remainingPercent.rounded() == 70) + #expect(usage.secondary?.remainingPercent.rounded() == 40) + } + + @Test + func `gemini pro bar unavailable when only excluded variants exist`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini Pro Lite", + modelId: "gemini-3-pro-lite", + remainingFraction: 0.6, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet 4", + modelId: "claude-sonnet-4", + remainingFraction: 0.3, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.secondary == nil) + #expect(usage.primary?.remainingPercent.rounded() == 30) + } + + @Test + func `gemini pro chooses pro low model`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro", + modelId: "gemini-3-pro", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro Low", + modelId: "gemini-3-pro-low", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.secondary?.remainingPercent.rounded() == 40) + } + + @Test + func `gemini pro low wins over standard pro when both exist`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro", + modelId: "gemini-3-pro", + remainingFraction: 0.1, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Pro Low", + modelId: "gemini-3-pro-low", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.secondary?.remainingPercent.rounded() == 90) + } + + @Test + func `gemini flash does not fallback to lite variant`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 2 Flash Lite", + modelId: "gemini-2-flash-lite", + remainingFraction: 0.2, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Claude Sonnet 4", + modelId: "claude-sonnet-4", + remainingFraction: 0.3, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.tertiary == nil) + #expect(usage.primary?.remainingPercent.rounded() == 30) + } + + @Test + func `falls back to labels when model ids are placeholders`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Sonnet 4.6", + modelId: "MODEL_PLACEHOLDER_M35", + remainingFraction: 0.3, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M36", + remainingFraction: 0.4, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "MODEL_PLACEHOLDER_M47", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary?.remainingPercent.rounded() == 30) + #expect(usage.secondary?.remainingPercent.rounded() == 40) + #expect(usage.tertiary?.remainingPercent.rounded() == 100) + } + + @Test + func `model without remaining fraction keeps reset time`() throws { + let resetTime = Date(timeIntervalSince1970: 1_735_000_000) + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M36", + remainingFraction: nil, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "MODEL_PLACEHOLDER_M47", + remainingFraction: 1, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: nil) + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.secondary?.remainingPercent.rounded() == 0) + #expect(usage.secondary?.resetsAt == resetTime) + #expect(usage.tertiary?.remainingPercent.rounded() == 100) + } + + @Test + func `filtered variants still produce a snapshot`() throws { + let snapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Gemini 3 Pro Lite", + modelId: "gemini-3-pro-lite", + remainingFraction: 0.6, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash Lite", + modelId: "gemini-3-flash-lite", + remainingFraction: 0.2, + resetTime: nil, + resetDescription: nil), + AntigravityModelQuota( + label: "Tab Autocomplete", + modelId: "tab_autocomplete_model", + remainingFraction: 0.9, + resetTime: nil, + resetDescription: nil), + ], + accountEmail: "test@example.com", + accountPlan: "Pro") + + let usage = try snapshot.toUsageSnapshot() + #expect(usage.primary == nil) + #expect(usage.secondary == nil) + #expect(usage.tertiary == nil) + #expect(usage.accountEmail(for: .antigravity) == "test@example.com") + #expect(usage.loginMethod(for: .antigravity) == "Pro") + } } diff --git a/Tests/CodexBarTests/MenuCardAntigravityTests.swift b/Tests/CodexBarTests/MenuCardAntigravityTests.swift new file mode 100644 index 000000000..6916e6d35 --- /dev/null +++ b/Tests/CodexBarTests/MenuCardAntigravityTests.swift @@ -0,0 +1,159 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct MenuCardAntigravityTests { + @Test + func `antigravity metrics show zero percent for missing families`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 5, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.count == 3) + #expect(model.metrics.map(\.title) == ["Claude", "Gemini Pro", "Gemini Flash"]) + #expect(model.metrics[1].percent == 0) + #expect(model.metrics[1].percentLabel == "0% left") + #expect(model.metrics[1].statusText == nil) + #expect(model.metrics[1].detailText == nil) + #expect(model.metrics[2].percent == 0) + #expect(model.metrics[2].percentLabel == "0% left") + #expect(model.metrics[2].statusText == nil) + #expect(model.metrics[2].detailText == nil) + } + + @Test + func `antigravity zero percent metric still shows reset text`() throws { + let now = Date(timeIntervalSince1970: 1_735_000_000) + let resetTime = now.addingTimeInterval(3600) + let antigravitySnapshot = AntigravityStatusSnapshot( + modelQuotas: [ + AntigravityModelQuota( + label: "Claude Thinking", + modelId: "MODEL_PLACEHOLDER_M35", + remainingFraction: 0.4, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3.1 Pro (Low)", + modelId: "MODEL_PLACEHOLDER_M36", + remainingFraction: nil, + resetTime: resetTime, + resetDescription: nil), + AntigravityModelQuota( + label: "Gemini 3 Flash", + modelId: "MODEL_PLACEHOLDER_M47", + remainingFraction: 1, + resetTime: resetTime, + resetDescription: nil), + ], + accountEmail: nil, + accountPlan: "Pro") + let snapshot = try antigravitySnapshot.toUsageSnapshot() + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics[1].percent == 0) + #expect(model.metrics[1].percentLabel == "0% left") + #expect(model.metrics[1].resetText != nil) + } + + @Test + func `antigravity missing families show full usage in used mode`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro") + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 5, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.antigravity]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .antigravity, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics[1].percent == 100) + #expect(model.metrics[1].percentLabel == "100% used") + #expect(model.metrics[2].percent == 100) + #expect(model.metrics[2].percentLabel == "100% used") + } +} diff --git a/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift new file mode 100644 index 000000000..153425060 --- /dev/null +++ b/Tests/CodexBarTests/MenuDescriptorAntigravityTests.swift @@ -0,0 +1,60 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +struct MenuDescriptorAntigravityTests { + @Test + func `antigravity menu does not add unavailable notes for missing families`() throws { + let suite = "MenuDescriptorAntigravityTests-missing-gemini" + let defaults = try #require(UserDefaults(suiteName: suite)) + defaults.removePersistentDomain(forName: suite) + + let settings = SettingsStore( + userDefaults: defaults, + configStore: testConfigStore(suiteName: suite), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.statusChecksEnabled = false + + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: Date().addingTimeInterval(3600), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .antigravity, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "Pro")) + store._setSnapshotForTesting(snapshot, provider: .antigravity) + + let descriptor = MenuDescriptor.build( + provider: .antigravity, + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updateReady: false, + includeContextualActions: false) + + let lines = descriptor.sections + .flatMap( + \.entries) + .compactMap { entry -> String? in + guard case let .text(text, _) = entry else { return nil } + return text + } + + #expect(!lines.contains("Gemini Pro unavailable.")) + #expect(!lines.contains("Gemini Flash unavailable.")) + } +}