diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b6e44c..5d32c29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,6 +70,7 @@ jobs: | xcbeautify --renderer github-actions - name: Test (Debug) + timeout-minutes: 5 run: | cd CopilotMonitor set -o pipefail @@ -78,6 +79,9 @@ jobs: -scheme CopilotMonitor \ -configuration Debug \ -destination 'platform=macOS' \ + -test-timeouts-enabled YES \ + -default-test-execution-time-allowance 30 \ + -maximum-test-execution-time-allowance 30 \ CODE_SIGN_IDENTITY="-" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ diff --git a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj index f23bf96..988069a 100644 --- a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj +++ b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj @@ -92,6 +92,7 @@ SYNTHETIC1111111111111111 /* SyntheticProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = SYNTHETIC2222222222222222 /* SyntheticProvider.swift */; }; SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */; }; NANOGPTTESTBF1111111111 /* NanoGptProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */; }; + CLIFMTTESTBF1111111111 /* CLIFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CLIFMTTESTFR1111111111 /* CLIFormatterTests.swift */; }; DEEPSKIPTESTBF1111111 /* DeepSeekProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEEPSKIPTESTFR1111111 /* DeepSeekProviderTests.swift */; }; TOKENTESTBF1111111111111 /* TokenManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TOKENTESTFR1111111111111 /* TokenManagerTests.swift */; }; CODEXTESTBF111111111111 /* CodexProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CODEXTESTFR111111111111 /* CodexProviderTests.swift */; }; @@ -238,6 +239,7 @@ SYNTHETIC2222222222222222 /* SyntheticProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticProvider.swift; sourceTree = ""; }; SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticProviderTests.swift; sourceTree = ""; }; NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NanoGptProviderTests.swift; sourceTree = ""; }; + CLIFMTTESTFR1111111111 /* CLIFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIFormatterTests.swift; sourceTree = ""; }; DEEPSKIPTESTFR1111111 /* DeepSeekProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepSeekProviderTests.swift; sourceTree = ""; }; TOKENTESTFR1111111111111 /* TokenManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenManagerTests.swift; sourceTree = ""; }; CODEXTESTFR111111111111 /* CodexProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexProviderTests.swift; sourceTree = ""; }; @@ -463,6 +465,7 @@ 54353FD130DDE0500F6B367F /* MenuResultBuilderTests.swift */, SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */, NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */, + CLIFMTTESTFR1111111111 /* CLIFormatterTests.swift */, DEEPSKIPTESTFR1111111 /* DeepSeekProviderTests.swift */, MINIMAXTESTFR11111111111 /* MiniMaxProviderTests.swift */, OCGOTESTFR11111111111 /* OpenCodeGoProviderTests.swift */, @@ -727,6 +730,7 @@ B58BAD3BFD97973070A2A892 /* MenuResultBuilderTests.swift in Sources */, SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */, NANOGPTTESTBF1111111111 /* NanoGptProviderTests.swift in Sources */, + CLIFMTTESTBF1111111111 /* CLIFormatterTests.swift in Sources */, DEEPSKIPTESTBF1111111 /* DeepSeekProviderTests.swift in Sources */, MINIMAXTESTBF11111111111 /* MiniMaxProviderTests.swift in Sources */, OCGOTESTBF11111111111 /* OpenCodeGoProviderTests.swift in Sources */, diff --git a/CopilotMonitor/CopilotMonitor/App/AppDelegate.swift b/CopilotMonitor/CopilotMonitor/App/AppDelegate.swift index 9351625..803b885 100644 --- a/CopilotMonitor/CopilotMonitor/App/AppDelegate.swift +++ b/CopilotMonitor/CopilotMonitor/App/AppDelegate.swift @@ -16,6 +16,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { } func applicationDidFinishLaunching(_ notification: Notification) { + // Hosted XCTest runs exercise their own controllers without starting the live app. + guard NSClassFromString("XCTestCase") == nil else { + logger.debug("XCTest host launch: application services are disabled") + return + } if AppMigrationHelper.shared.checkAndMigrateIfNeeded() { return } @@ -29,7 +34,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate { ) configureAutomaticUpdates() - statusBarController = StatusBarController() + statusBarController = StatusBarController(startBackgroundServices: true) closeAllWindows() } diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index 7b4f7db..d1709f1 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -11,7 +11,7 @@ private enum StatusBarMetricKind { case usage } -private enum UsageDisplayWindowPriority: Int, CaseIterable { +enum UsageDisplayWindowPriority: Int, CaseIterable { case weekly = 0 case monthly = 1 case daily = 2 @@ -19,7 +19,7 @@ private enum UsageDisplayWindowPriority: Int, CaseIterable { case fallback = 4 } -private struct UsagePercentCandidate { +struct UsagePercentCandidate { let percent: Double let priority: UsageDisplayWindowPriority } @@ -273,19 +273,24 @@ final class StatusBarController: NSObject { } } - override init() { + init(startBackgroundServices: Bool) { super.init() debugLog("StatusBarController init started") - TokenManager.shared.logDebugEnvironmentInfo() - debugLog("Environment debug info logged") - - ensureBraveRefreshModeDefault() + if startBackgroundServices { + TokenManager.shared.logDebugEnvironmentInfo() + ensureBraveRefreshModeDefault() + } setupStatusItem() debugLog("setupStatusItem completed") setupMenu() debugLog("setupMenu completed") + // Menu tests must not launch credential discovery, network refreshes, or modal prompts. + guard startBackgroundServices else { + logger.debug("Menu initialized without background services") + return + } setupNotificationObservers() debugLog("setupNotificationObservers completed") startRefreshTimer() @@ -959,7 +964,20 @@ final class StatusBarController: NSObject { return details.chutesMonthlyValueUsedPercent } - private func usagePercentCandidates( + /// Window percentages shown on the Z.AI top-level quota/provider row. + /// Unlike the status-bar candidate list (priority-ordered), the top-level + /// row shows every active window side by side, so the Lite weekly window + /// must be included here too — omitting it makes the row diverge from the + /// usage windows (5h session, weekly, MCP monthly). + private static func zaiCodingPlanTopLevelPercents(details: DetailedUsage?) -> [Double] { + [ + details?.tokenUsagePercent, + details?.weeklyUsagePercent, + details?.mcpUsagePercent + ].compactMap { $0 } + } + + static func usagePercentCandidates( identifier: ProviderIdentifier, usage: ProviderUsage, details: DetailedUsage? @@ -1024,6 +1042,7 @@ final class StatusBarController: NSObject { case .zaiCodingPlan: add(details?.mcpUsagePercent, priority: .monthly) add(details?.tokenUsagePercent, priority: .hourly) + add(details?.weeklyUsagePercent, priority: .weekly) case .nanoGpt: add(details?.sevenDayUsage, priority: .weekly) case .chutes: @@ -1046,7 +1065,7 @@ final class StatusBarController: NSObject { usage: ProviderUsage, details: DetailedUsage? ) -> Double? { - let candidates = usagePercentCandidates(identifier: identifier, usage: usage, details: details) + let candidates = Self.usagePercentCandidates(identifier: identifier, usage: usage, details: details) guard let selectedPriority = candidates.map(\.priority.rawValue).min() else { return nil } @@ -1068,7 +1087,7 @@ final class StatusBarController: NSObject { // Main result candidates if case .quotaBased = result.usage { allCandidates.append(contentsOf: - usagePercentCandidates(identifier: identifier, usage: result.usage, details: result.details) + Self.usagePercentCandidates(identifier: identifier, usage: result.usage, details: result.details) ) } @@ -1077,7 +1096,7 @@ final class StatusBarController: NSObject { for account in accounts { guard case .quotaBased = account.usage else { continue } allCandidates.append(contentsOf: - usagePercentCandidates(identifier: identifier, usage: account.usage, details: account.details) + Self.usagePercentCandidates(identifier: identifier, usage: account.usage, details: account.details) ) } } @@ -1103,7 +1122,7 @@ final class StatusBarController: NSObject { .max() } - private func usedPercentsForChangeDetection(identifier: ProviderIdentifier, result: ProviderResult) -> [Double] { + static func usedPercentsForChangeDetection(identifier: ProviderIdentifier, result: ProviderResult) -> [Double] { var usedPercents: [Double] = [] func appendMetrics(usage: ProviderUsage, details: DetailedUsage?) { @@ -1126,6 +1145,7 @@ final class StatusBarController: NSObject { details.cursorApiUsage, details.tokenUsagePercent, details.mcpUsagePercent, + details.weeklyUsagePercent, details.openCodeGoMonthlyUsage ] for percent in extraPercents { @@ -1163,7 +1183,7 @@ final class StatusBarController: NSObject { kind: .cost ) case .quotaBased: - let cappedPercents = usedPercentsForChangeDetection(identifier: identifier, result: result).map { min($0, 100.0) } + let cappedPercents = Self.usedPercentsForChangeDetection(identifier: identifier, result: result).map { min($0, 100.0) } // Use aggregate quota usage for change detection so non-max windows/accounts can still trigger updates. let aggregatePercent = cappedPercents.isEmpty ? min(max(result.usage.usagePercentage, 0.0), 100.0) @@ -2144,7 +2164,7 @@ final class StatusBarController: NSObject { ].compactMap { $0 } usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents } else if identifier == .zaiCodingPlan { - let percents = [account.details?.tokenUsagePercent, account.details?.mcpUsagePercent].compactMap { $0 } + let percents = Self.zaiCodingPlanTopLevelPercents(details: account.details) usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents } else if identifier == .chutes { let percents = [Self.dailyPercentFromDetails(account.details), Self.chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 } @@ -2230,7 +2250,7 @@ final class StatusBarController: NSObject { ].compactMap { $0 } usedPercents = percents.isEmpty ? [singlePercent] : percents } else if identifier == .zaiCodingPlan { - let percents = [result.details?.tokenUsagePercent, result.details?.mcpUsagePercent].compactMap { $0 } + let percents = Self.zaiCodingPlanTopLevelPercents(details: result.details) usedPercents = percents.isEmpty ? [singlePercent] : percents } else if identifier == .chutes { let percents = [Self.dailyPercentFromDetails(result.details), Self.chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 } @@ -4258,22 +4278,33 @@ extension StatusBarController { ) ), .zaiCodingPlan: ProviderResult( - usage: .quotaBased(remaining: 1, entitlement: 100, overagePermitted: false), + usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false), details: DetailedUsage( - tokenUsagePercent: 99.0, + tokenUsagePercent: 12.0, tokenUsageReset: oneDayFromNow, - tokenUsageUsed: 990_000, - tokenUsageTotal: 1_000_000, - mcpUsagePercent: 45.0, + mcpUsagePercent: 2.0, mcpUsageReset: oneDayFromNow, - mcpUsageUsed: 45, - mcpUsageTotal: 100, - modelUsageTokens: 500_000, - modelUsageCalls: 128, - toolNetworkSearchCount: 42, - toolWebReadCount: 15, - toolZreadCount: 8 - ) + weeklyUsagePercent: 1.0, + weeklyUsageReset: sevenDaysFromNow + ), + accounts: [ + ProviderAccountResult( + accountIndex: 0, + accountId: "zai-session", + usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false), + details: DetailedUsage( + tokenUsagePercent: 12.0, + mcpUsagePercent: 2.0, + weeklyUsagePercent: 1.0 + ) + ), + ProviderAccountResult( + accountIndex: 1, + accountId: "zai-weekly", + usage: .quotaBased(remaining: 99, entitlement: 100, overagePermitted: false), + details: DetailedUsage(weeklyUsagePercent: 1.0) + ) + ] ), .geminiCLI: ProviderResult( usage: .quotaBased(remaining: 85, entitlement: 100, overagePermitted: false), diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 54af183..d098abe 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -739,6 +739,22 @@ extension StatusBarController { submenu.addItem(item) } + // === Weekly Usage (CREDIT_LIMIT unit=6, lite tier) === + if let weeklyUsage = details.weeklyUsagePercent { + let items = createUsageWindowRow( + label: "Weekly (7d)", + usagePercent: weeklyUsage, + resetDate: details.weeklyUsageReset, + windowHours: 24 * 7, + isMonthly: false + ) + items.forEach { submenu.addItem($0) } + } + if let weeklyUsed = details.weeklyUsageUsed, let weeklyTotal = details.weeklyUsageTotal { + let item = createLimitRow(label: "Weekly", used: Double(weeklyUsed), total: Double(weeklyTotal)) + submenu.addItem(item) + } + // === Last 24h stats (provider-specific, keep as-is) === let numberFormatter = NumberFormatter() numberFormatter.numberStyle = .decimal diff --git a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift index 79d66fc..80d595b 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift @@ -223,6 +223,12 @@ struct DetailedUsage { let mcpUsageReset: Date? let mcpUsageUsed: Int? let mcpUsageTotal: Int? + /// Second CREDIT_LIMIT window (lite tier): rolling 7-day weekly quota + /// (unit=6). Populated when a plan only reports CREDIT_LIMIT items. + let weeklyUsagePercent: Double? + let weeklyUsageReset: Date? + let weeklyUsageUsed: Int? + let weeklyUsageTotal: Int? let modelUsageTokens: Int? let modelUsageCalls: Int? let toolNetworkSearchCount: Int? @@ -314,6 +320,10 @@ struct DetailedUsage { mcpUsageReset: Date? = nil, mcpUsageUsed: Int? = nil, mcpUsageTotal: Int? = nil, + weeklyUsagePercent: Double? = nil, + weeklyUsageReset: Date? = nil, + weeklyUsageUsed: Int? = nil, + weeklyUsageTotal: Int? = nil, modelUsageTokens: Int? = nil, modelUsageCalls: Int? = nil, toolNetworkSearchCount: Int? = nil, @@ -400,6 +410,10 @@ struct DetailedUsage { self.mcpUsageReset = mcpUsageReset self.mcpUsageUsed = mcpUsageUsed self.mcpUsageTotal = mcpUsageTotal + self.weeklyUsagePercent = weeklyUsagePercent + self.weeklyUsageReset = weeklyUsageReset + self.weeklyUsageUsed = weeklyUsageUsed + self.weeklyUsageTotal = weeklyUsageTotal self.modelUsageTokens = modelUsageTokens self.modelUsageCalls = modelUsageCalls self.toolNetworkSearchCount = toolNetworkSearchCount @@ -437,6 +451,7 @@ extension DetailedUsage: Codable { case authSource, authUsageSummary, authErrorMessage, geminiAccounts case tokenUsagePercent, tokenUsageReset, tokenUsageUsed, tokenUsageTotal case mcpUsagePercent, mcpUsageReset, mcpUsageUsed, mcpUsageTotal + case weeklyUsagePercent, weeklyUsageReset, weeklyUsageUsed, weeklyUsageTotal case modelUsageTokens, modelUsageCalls case toolNetworkSearchCount, toolWebReadCount, toolZreadCount case cursorAutoUsage, cursorAutoReset, cursorApiUsage, cursorApiReset @@ -516,6 +531,10 @@ extension DetailedUsage: Codable { mcpUsageReset = try container.decodeIfPresent(Date.self, forKey: .mcpUsageReset) mcpUsageUsed = try container.decodeIfPresent(Int.self, forKey: .mcpUsageUsed) mcpUsageTotal = try container.decodeIfPresent(Int.self, forKey: .mcpUsageTotal) + weeklyUsagePercent = try container.decodeIfPresent(Double.self, forKey: .weeklyUsagePercent) + weeklyUsageReset = try container.decodeIfPresent(Date.self, forKey: .weeklyUsageReset) + weeklyUsageUsed = try container.decodeIfPresent(Int.self, forKey: .weeklyUsageUsed) + weeklyUsageTotal = try container.decodeIfPresent(Int.self, forKey: .weeklyUsageTotal) modelUsageTokens = try container.decodeIfPresent(Int.self, forKey: .modelUsageTokens) modelUsageCalls = try container.decodeIfPresent(Int.self, forKey: .modelUsageCalls) toolNetworkSearchCount = try container.decodeIfPresent(Int.self, forKey: .toolNetworkSearchCount) @@ -605,6 +624,10 @@ extension DetailedUsage: Codable { try container.encodeIfPresent(mcpUsageReset, forKey: .mcpUsageReset) try container.encodeIfPresent(mcpUsageUsed, forKey: .mcpUsageUsed) try container.encodeIfPresent(mcpUsageTotal, forKey: .mcpUsageTotal) + try container.encodeIfPresent(weeklyUsagePercent, forKey: .weeklyUsagePercent) + try container.encodeIfPresent(weeklyUsageReset, forKey: .weeklyUsageReset) + try container.encodeIfPresent(weeklyUsageUsed, forKey: .weeklyUsageUsed) + try container.encodeIfPresent(weeklyUsageTotal, forKey: .weeklyUsageTotal) try container.encodeIfPresent(modelUsageTokens, forKey: .modelUsageTokens) try container.encodeIfPresent(modelUsageCalls, forKey: .modelUsageCalls) try container.encodeIfPresent(toolNetworkSearchCount, forKey: .toolNetworkSearchCount) @@ -748,7 +771,7 @@ struct JSONFormatter { } } - // Z.AI: include both token and MCP usage percentages + // Z.AI: include token, MCP and (lite tier) weekly usage windows if identifier == .zaiCodingPlan { if let tokenPercent = result.details?.tokenUsagePercent { providerDict["tokenUsagePercent"] = tokenPercent @@ -756,6 +779,19 @@ struct JSONFormatter { if let mcpPercent = result.details?.mcpUsagePercent { providerDict["mcpUsagePercent"] = mcpPercent } + if let weeklyPercent = result.details?.weeklyUsagePercent { + providerDict["weeklyUsagePercent"] = weeklyPercent + } + if let weeklyUsed = result.details?.weeklyUsageUsed { + providerDict["weeklyUsageUsed"] = weeklyUsed + } + if let weeklyTotal = result.details?.weeklyUsageTotal { + providerDict["weeklyUsageTotal"] = weeklyTotal + } + if let weeklyReset = result.details?.weeklyUsageReset { + let formatter = ISO8601DateFormatter() + providerDict["weeklyResetsAt"] = formatter.string(from: weeklyReset) + } } if identifier == .geminiCLI, let accounts = result.details?.geminiAccounts, !accounts.isEmpty { @@ -1043,10 +1079,14 @@ struct TableFormatter { if identifier == .grok, let monthlyUsage = result.details?.monthlyUsage { return UsagePercentDisplayFormatter.string(from: monthlyUsage) } - // Z.AI: show both token and MCP percentages when both are available + // Z.AI: show token/MCP/weekly window percentages when available if identifier == .zaiCodingPlan { - let percents = [result.details?.tokenUsagePercent, result.details?.mcpUsagePercent].compactMap { $0 } - if percents.count == 2 { + let percents = [ + result.details?.tokenUsagePercent, + result.details?.mcpUsagePercent, + result.details?.weeklyUsagePercent + ].compactMap { $0 } + if percents.count >= 2 { return percents.map { UsagePercentDisplayFormatter.string(from: $0) }.joined(separator: ",") } } @@ -1503,6 +1543,7 @@ extension DetailedUsage { || secondaryUsage != nil || secondaryReset != nil || primaryReset != nil || sparkUsage != nil || sparkReset != nil || sparkSecondaryUsage != nil || sparkSecondaryReset != nil || sparkWindowLabel != nil || creditsBalance != nil || planType != nil + || weeklyUsagePercent != nil || weeklyUsageReset != nil || weeklyUsageUsed != nil || weeklyUsageTotal != nil || chutesMonthlyValueCapUSD != nil || chutesMonthlyValueUsedUSD != nil || chutesMonthlyValueUsedPercent != nil || openCodeGoMonthlyUsage != nil || openCodeGoMonthlyReset != nil || openCodeGoModelCount != nil || extraUsageEnabled != nil diff --git a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift index 0275236..9be127b 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -7,20 +7,40 @@ private struct ZaiEnvelope: Decodable { let data: T? } -private struct ZaiQuotaLimitResponse: Decodable { +struct ZaiQuotaLimitResponse: Decodable { let limits: [ZaiQuotaLimitItem]? } -private struct ZaiQuotaLimitItem: Decodable { +struct ZaiQuotaLimitItem: Decodable { let type: String let percentage: Double? let currentValue: Int? let total: Int? let nextResetTime: Int64? + /// CREDIT_LIMIT items (lite tier) report capacity as `usage` when `total` + /// is absent. `currentValue` remains the consumed amount, while + /// `remaining` is server-reported leftover metadata. + let usage: Int? + /// Optional duration metadata for identifying known CREDIT_LIMIT windows. + let number: Int? + let remaining: Int? + /// Window unit used to distinguish the plan's rolling windows: + /// unit=3 (hours) -> 5-hour session quota, unit=6 (weeks) -> 7-day weekly quota. + /// See docs.z.ai FAQ and third-party parsers (ClaudeBar ZaiUsageProbe, token-monitor). + let unit: Int? + + /// Resolved total capacity: prefers `total` (TOKENS_LIMIT / TIME_LIMIT), + /// falls back to `usage` (CREDIT_LIMIT). + var resolvedTotal: Int? { + total ?? usage + } var computedPercentage: Double? { - guard let currentValue = currentValue, let total = total, total > 0 else { return nil } - return (Double(currentValue) / Double(total)) * 100 + if let percentage { + return percentage + } + guard let currentValue, let resolvedTotal, resolvedTotal > 0 else { return nil } + return (Double(currentValue) / Double(resolvedTotal)) * 100 } private enum CodingKeys: String, CodingKey { @@ -29,6 +49,10 @@ private struct ZaiQuotaLimitItem: Decodable { case currentValue case total case nextResetTime + case usage + case unit + case number + case remaining } init(from decoder: Decoder) throws { @@ -38,6 +62,10 @@ private struct ZaiQuotaLimitItem: Decodable { currentValue = Self.decodeInt(container, forKey: .currentValue) total = Self.decodeInt(container, forKey: .total) nextResetTime = Self.decodeInt64(container, forKey: .nextResetTime) + usage = Self.decodeInt(container, forKey: .usage) + unit = Self.decodeInt(container, forKey: .unit) + number = Self.decodeInt(container, forKey: .number) + remaining = Self.decodeInt(container, forKey: .remaining) } private static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: CodingKeys) -> Double? { @@ -159,16 +187,19 @@ final class ZaiCodingPlanProvider: ProviderProtocol { private let tokenManager: TokenManager private let session: URLSession + /// Optional injected API key for tests; falls back to the credential store. + private let apiKeyOverride: String? - init(tokenManager: TokenManager = .shared, session: URLSession = .shared) { + init(tokenManager: TokenManager = .shared, session: URLSession = .shared, apiKey: String? = nil) { self.tokenManager = tokenManager self.session = session + self.apiKeyOverride = apiKey } func fetch() async throws -> ProviderResult { logger.info("Z.AI Coding Plan fetch started") - guard let apiKey = tokenManager.getZaiCodingPlanAPIKey() else { + guard let apiKey = apiKeyOverride ?? tokenManager.getZaiCodingPlanAPIKey() else { logger.error("Z.AI Coding Plan API key not found") throw ProviderError.authenticationFailed("Z.AI Coding Plan API key not available") } @@ -179,18 +210,38 @@ final class ZaiCodingPlanProvider: ProviderProtocol { throw ProviderError.decodingError("Missing quota limits") } + // Standard schema (unchanged): TOKENS_LIMIT -> 5h token window, + // TIME_LIMIT -> MCP window. let tokenLimit = limits.first { $0.type.uppercased() == "TOKENS_LIMIT" } let mcpLimit = limits.first { $0.type.uppercased() == "TIME_LIMIT" } + // New schema (lite tier): only CREDIT_LIMIT items are returned. `usage` + // supplies capacity when `total` is absent, `currentValue` remains the + // consumed amount, and `remaining` is server-reported leftover metadata. + // The two known rolling windows are identified by unit plus optional + // duration metadata: unit=3/number=5 is the 5-hour session quota, and + // unit=6/number=1 is the 7-day weekly quota. Missing number preserves + // compatibility with older responses; contradictory values are ignored. + let creditLimits = limits.filter { $0.type.uppercased() == "CREDIT_LIMIT" } + let isCreditOnlySchema = tokenLimit == nil && mcpLimit == nil + let creditSessionLimit = isCreditOnlySchema + ? creditLimits.first { $0.unit == 3 && ($0.number == nil || $0.number == 5) } + : nil + let creditWeeklyLimit = isCreditOnlySchema + ? creditLimits.first { $0.unit == 6 && ($0.number == nil || $0.number == 1) } + : nil + let tokenUsagePercent = tokenLimit?.percentage ?? tokenLimit?.computedPercentage + ?? creditSessionLimit?.percentage ?? creditSessionLimit?.computedPercentage let mcpUsagePercent = mcpLimit?.percentage ?? mcpLimit?.computedPercentage + let weeklyUsagePercent = creditWeeklyLimit?.percentage ?? creditWeeklyLimit?.computedPercentage - guard tokenUsagePercent != nil || mcpUsagePercent != nil else { + guard tokenUsagePercent != nil || mcpUsagePercent != nil || weeklyUsagePercent != nil else { logger.error("Z.AI Coding Plan quota limits missing percentage values") throw ProviderError.decodingError("Missing usage percentages") } - let overallUsed = max(tokenUsagePercent ?? 0, mcpUsagePercent ?? 0) + let overallUsed = max(tokenUsagePercent ?? 0, mcpUsagePercent ?? 0, weeklyUsagePercent ?? 0) let remainingPercent = Int((100.0 - overallUsed).rounded()) let usage = ProviderUsage.quotaBased( @@ -226,13 +277,17 @@ final class ZaiCodingPlanProvider: ProviderProtocol { let details = DetailedUsage( authSource: "~/.local/share/opencode/auth.json", tokenUsagePercent: tokenUsagePercent, - tokenUsageReset: dateFromMilliseconds(tokenLimit?.nextResetTime), - tokenUsageUsed: tokenLimit?.currentValue, - tokenUsageTotal: tokenLimit?.total, + tokenUsageReset: dateFromMilliseconds((tokenLimit ?? creditSessionLimit)?.nextResetTime), + tokenUsageUsed: (tokenLimit ?? creditSessionLimit)?.currentValue, + tokenUsageTotal: (tokenLimit ?? creditSessionLimit)?.resolvedTotal, mcpUsagePercent: mcpUsagePercent, mcpUsageReset: dateFromMilliseconds(mcpLimit?.nextResetTime), mcpUsageUsed: mcpLimit?.currentValue, - mcpUsageTotal: mcpLimit?.total, + mcpUsageTotal: mcpLimit?.resolvedTotal, + weeklyUsagePercent: weeklyUsagePercent, + weeklyUsageReset: dateFromMilliseconds(creditWeeklyLimit?.nextResetTime), + weeklyUsageUsed: creditWeeklyLimit?.currentValue, + weeklyUsageTotal: creditWeeklyLimit?.resolvedTotal, modelUsageTokens: modelUsageTotals?.totalTokensUsage, modelUsageCalls: modelUsageTotals?.totalModelCallCount, toolNetworkSearchCount: toolUsageTotals?.totalNetworkSearchCount, diff --git a/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift b/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift index 977ea9b..d336de2 100644 --- a/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift @@ -47,7 +47,7 @@ final class CLIFormatterTests: XCTestCase { func testQuotaBasedOverage() { let usage = ProviderUsage.quotaBased(remaining: -10, entitlement: 100, overagePermitted: true) - XCTAssertEqual(usage.usagePercentage, 110.0) + XCTAssertEqual(usage.usagePercentage, 110.0, accuracy: 0.000_001) } // MARK: - ProviderUsage Limit Tests @@ -428,6 +428,40 @@ final class CLIFormatterTests: XCTestCase { "Separator must be at least as wide as every data row. Row: \(row)") } } + // MARK: - Z.AI CREDIT_LIMIT (lite tier) formatter tests + + private func zaiCreditOnlyResult() -> ProviderResult { + let details = DetailedUsage( + tokenUsagePercent: 1, + tokenUsageReset: Date(timeIntervalSince1970: 1786717056), + tokenUsageUsed: 27, + tokenUsageTotal: 2000, + weeklyUsagePercent: 1, + weeklyUsageReset: Date(timeIntervalSince1970: 1787301777), + weeklyUsageUsed: 27, + weeklyUsageTotal: 10000 + ) + let usage = ProviderUsage.quotaBased(remaining: 99, entitlement: 100, overagePermitted: false) + return ProviderResult(usage: usage, details: details) + } + + /// Table must surface both the 5-hour session window and the weekly window. + func testZaiTableShowsBothCreditWindows() { + let output = TableFormatter.format([.zaiCodingPlan: zaiCreditOnlyResult()]) + XCTAssertTrue(output.contains("1%,1%"), "Usage column should show both windows, got:\n\(output)") + XCTAssertTrue(output.contains("99/100 remaining"), "Metrics should show overall remaining, got:\n\(output)") + } + + /// JSON must include the weekly window fields alongside token/MCP. + func testZaiJSONIncludesWeeklyWindow() throws { + let json = try JSONFormatter.format([.zaiCodingPlan: zaiCreditOnlyResult()]) + XCTAssertTrue(json.contains("\"tokenUsagePercent\" : 1"), "Missing tokenUsagePercent in:\n\(json)") + XCTAssertTrue(json.contains("\"weeklyUsagePercent\" : 1"), "Missing weeklyUsagePercent in:\n\(json)") + XCTAssertTrue(json.contains("\"weeklyUsageUsed\" : 27"), "Missing weeklyUsageUsed in:\n\(json)") + XCTAssertTrue(json.contains("\"weeklyUsageTotal\" : 10000"), "Missing weeklyUsageTotal in:\n\(json)") + XCTAssertTrue(json.contains("\"weeklyResetsAt\""), "Missing weeklyResetsAt in:\n\(json)") + } + // MARK: - Balance-style pay-as-you-go formatter tests (DeepSeek) /// Table metrics must show the remaining balance (CNY) instead of @@ -460,11 +494,14 @@ final class CLIFormatterTests: XCTestCase { let result = ProviderResult(usage: usage, details: details) let json = try JSONFormatter.format([.deepSeek: result]) - XCTAssertTrue(json.contains("\"balance\" : 103.49"), "Missing balance in:\n\(json)") - XCTAssertTrue(json.contains("\"currency\" : \"CNY\""), "Missing currency in:\n\(json)") - XCTAssertTrue(json.contains("\"grantedBalance\" : 0"), "Missing grantedBalance in:\n\(json)") - XCTAssertTrue(json.contains("\"toppedUpBalance\" : 103.49"), "Missing toppedUpBalance in:\n\(json)") - XCTAssertFalse(json.contains("\"cost\""), "cost must stay nil for balance-style providers:\n\(json)") + let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: [String: Any]]) + let provider = try XCTUnwrap(payload["deepseek"]) + // Compare JSON numbers rather than their platform-dependent decimal spelling. + XCTAssertEqual(try XCTUnwrap(provider["balance"] as? Double), 103.49, accuracy: 0.000_001) + XCTAssertEqual(provider["currency"] as? String, "CNY") + XCTAssertEqual(provider["grantedBalance"] as? Double, 0) + XCTAssertEqual(try XCTUnwrap(provider["toppedUpBalance"] as? Double), 103.49, accuracy: 0.000_001) + XCTAssertNil(provider["cost"]) } /// Providers with a real cost keep the existing "$x spent" rendering. diff --git a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index 9f5add6..3c8f2d5 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -2,6 +2,69 @@ import XCTest @testable import OpenCode_Bar final class ZaiCodingPlanProviderTests: XCTestCase { + private final class MockURLProtocol: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override static func canInit(with request: URLRequest) -> Bool { + true + } + + override static func canonicalRequest(for request: URLRequest) -> URLRequest { + request + } + + override func startLoading() { + guard let handler = MockURLProtocol.requestHandler else { + client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse)) + return + } + + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + } + + private func makeSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: configuration) + } + + /// Read the menu produced by the real controller build path without adding + /// a production-only test accessor to StatusBarController. + @MainActor + private func menu(from controller: StatusBarController) -> NSMenu? { + guard let value = Mirror(reflecting: controller).children + .first(where: { $0.label == "menu" })?.value else { + return nil + } + return unwrapMenu(value) + } + + private func unwrapMenu(_ value: Any) -> NSMenu? { + if let menu = value as? NSMenu { + return menu + } + let mirror = Mirror(reflecting: value) + guard mirror.displayStyle == .optional, + let child = mirror.children.first else { + return nil + } + return unwrapMenu(child.value) + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + super.tearDown() + } func testProviderIdentifier() { let provider = ZaiCodingPlanProvider() @@ -13,6 +76,406 @@ final class ZaiCodingPlanProviderTests: XCTestCase { XCTAssertEqual(provider.type, .quotaBased) } + // MARK: - Helpers + + /// Real Lite-tier response shape: only CREDIT_LIMIT items, two rolling windows + /// (unit=3 -> 5-hour session, unit=6 -> 7-day weekly), usage/remaining instead + /// of total, no TOKENS_LIMIT / TIME_LIMIT. + private let creditOnlyJSON = """ + { + "data": { + "limits": [ + { + "type": "CREDIT_LIMIT", + "unit": 3, + "number": 5, + "usage": 2000, + "currentValue": 27, + "remaining": 1972, + "percentage": 1, + "nextResetTime": 1786717056698 + }, + { + "type": "CREDIT_LIMIT", + "unit": 6, + "number": 1, + "usage": 10000, + "currentValue": 27, + "remaining": 9972, + "percentage": 1, + "nextResetTime": 1787301777997 + } + ], + "level": "lite" + } + } + """ + + private let modelUsageJSON = """ + {"data": {"totalUsage": {"totalTokensUsage": 120, "totalModelCallCount": 8}}} + """ + + private let toolUsageJSON = """ + {"data": {"totalUsage": {"totalNetworkSearchCount": 1, "totalWebReadMcpCount": 2, "totalZreadMcpCount": 3}}} + """ + + /// Installs a mock session that serves quota/model/tool endpoints and runs + /// the real `fetch()` pipeline with an injected API key (no credential store). + private func makeProvider(quotaJSON: String) -> ZaiCodingPlanProvider { + let session = makeSession() + let provider = ZaiCodingPlanProvider(tokenManager: .shared, session: session, apiKey: "sk-test-fake") + + MockURLProtocol.requestHandler = { request in + let url = try XCTUnwrap(request.url) + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)! + let body: String + if url.path.contains("quota/limit") { + body = quotaJSON + } else if url.path.contains("model-usage") { + body = self.modelUsageJSON + } else if url.path.contains("tool-usage") { + body = self.toolUsageJSON + } else { + body = "{}" + } + return (response, Data(body.utf8)) + } + return provider + } + + // MARK: - CREDIT_LIMIT-only (lite tier) + + /// Provider-level regression: a CREDIT_LIMIT-only response with BOTH windows + /// must surface the 5-hour window as token usage AND the weekly window via + /// the weekly fields — not drop the second window. + func testCreditOnlyResponsePopulatesBothWindows() async throws { + let result = try await makeProvider(quotaJSON: creditOnlyJSON).fetch() + let details = try XCTUnwrap(result.details) + + // 5-hour session window (unit=3, usage=2000) + XCTAssertEqual(details.tokenUsagePercent, 1) + XCTAssertEqual(details.tokenUsageUsed, 27) + XCTAssertEqual(details.tokenUsageTotal, 2000) + XCTAssertNotNil(details.tokenUsageReset) + + // Weekly window (unit=6, usage=10000) + XCTAssertEqual(details.weeklyUsagePercent, 1) + XCTAssertEqual(details.weeklyUsageUsed, 27) + XCTAssertEqual(details.weeklyUsageTotal, 10000) + XCTAssertNotNil(details.weeklyUsageReset) + // The observed fixture has 27 + 1972 = 1999 while usage is 2000; + // successful fetch proves no exact remaining arithmetic is required. + + // No MCP window in this schema + XCTAssertNil(details.mcpUsagePercent) + + // Model/tool usage still fetched + XCTAssertEqual(details.modelUsageTokens, 120) + XCTAssertEqual(details.toolNetworkSearchCount, 1) + } + + func testCreditOnlySingleWindowStillRenders() async throws { + // A response with only the weekly window (no unit=3 item) must map it + // to the weekly fields and NOT double-fill the session/token fields. + let singleWindow = """ + {"data": {"limits": [ + {"type": "CREDIT_LIMIT", "unit": 6, "number": 1, "usage": 10000, + "currentValue": 27, "remaining": 9972, "percentage": 1, + "nextResetTime": 1787301777997} + ], "level": "lite"}} + """ + let result = try await makeProvider(quotaJSON: singleWindow).fetch() + let details = try XCTUnwrap(result.details) + XCTAssertNil(details.tokenUsageTotal) + XCTAssertNil(details.tokenUsageUsed) + XCTAssertEqual(details.weeklyUsageTotal, 10000) + XCTAssertEqual(details.weeklyUsageUsed, 27) + } + + func testCreditLimitUnitThreeWithFutureDurationIsNotMappedAsFiveHour() async throws { + let futureHourWindow = """ + {"data": {"limits": [ + {"type": "CREDIT_LIMIT", "unit": 3, "number": 10, "usage": 4000, + "currentValue": 40, "remaining": 3960, "percentage": 1}, + {"type": "CREDIT_LIMIT", "unit": 6, "number": 1, "usage": 10000, + "currentValue": 27, "remaining": 9972, "percentage": 1} + ]}} + """ + let result = try await makeProvider(quotaJSON: futureHourWindow).fetch() + let details = try XCTUnwrap(result.details) + + XCTAssertNil(details.tokenUsagePercent) + XCTAssertNil(details.tokenUsageUsed) + XCTAssertNil(details.tokenUsageTotal) + XCTAssertEqual(details.weeklyUsagePercent, 1) + XCTAssertEqual(details.weeklyUsageTotal, 10000) + } + + func testCreditLimitUnitSixWithFutureDurationIsNotMappedAsWeekly() async throws { + let futureWeeklyWindow = """ + {"data": {"limits": [ + {"type": "CREDIT_LIMIT", "unit": 3, "number": 5, "usage": 2000, + "currentValue": 27, "remaining": 1972, "percentage": 1}, + {"type": "CREDIT_LIMIT", "unit": 6, "number": 2, "usage": 20000, + "currentValue": 100, "remaining": 19900, "percentage": 1} + ]}} + """ + let result = try await makeProvider(quotaJSON: futureWeeklyWindow).fetch() + let details = try XCTUnwrap(result.details) + + XCTAssertEqual(details.tokenUsagePercent, 1) + XCTAssertEqual(details.tokenUsageTotal, 2000) + XCTAssertNil(details.weeklyUsagePercent) + XCTAssertNil(details.weeklyUsageUsed) + XCTAssertNil(details.weeklyUsageTotal) + } + + func testCreditLimitUnitThreeWithoutNumberUsesCompatibilityFallback() async throws { + let legacySessionWindow = """ + {"data": {"limits": [ + {"type": "CREDIT_LIMIT", "unit": 3, "usage": 2000, + "currentValue": 27, "percentage": 1} + ]}} + """ + let result = try await makeProvider(quotaJSON: legacySessionWindow).fetch() + let details = try XCTUnwrap(result.details) + + XCTAssertEqual(details.tokenUsagePercent, 1) + XCTAssertEqual(details.tokenUsageUsed, 27) + XCTAssertEqual(details.tokenUsageTotal, 2000) + XCTAssertNil(details.weeklyUsagePercent) + } + + func testCreditLimitUnitSixWithoutNumberUsesCompatibilityFallback() async throws { + let legacyWeeklyWindow = """ + {"data": {"limits": [ + {"type": "CREDIT_LIMIT", "unit": 6, "usage": 10000, + "currentValue": 27, "percentage": 1} + ]}} + """ + let result = try await makeProvider(quotaJSON: legacyWeeklyWindow).fetch() + let details = try XCTUnwrap(result.details) + + XCTAssertNil(details.tokenUsagePercent) + XCTAssertEqual(details.weeklyUsagePercent, 1) + XCTAssertEqual(details.weeklyUsageUsed, 27) + XCTAssertEqual(details.weeklyUsageTotal, 10000) + } + + // MARK: - Standard schema (unchanged behavior) + + /// Old TOKENS_LIMIT / TIME_LIMIT schema must keep working exactly as before + /// and must NOT pick up any CREDIT_LIMIT fallback when token windows exist. + func testStandardSchemaUnchanged() async throws { + let standardJSON = """ + {"data": {"limits": [ + {"type": "TOKENS_LIMIT", "total": 5000, "currentValue": 100, "percentage": 2, + "nextResetTime": 1786717056698}, + {"type": "TIME_LIMIT", "total": 300, "currentValue": 12, "percentage": 4, + "nextResetTime": 1787400000000} + ]}} + """ + let result = try await makeProvider(quotaJSON: standardJSON).fetch() + let details = try XCTUnwrap(result.details) + XCTAssertEqual(details.tokenUsagePercent, 2) + XCTAssertEqual(details.tokenUsageUsed, 100) + XCTAssertEqual(details.tokenUsageTotal, 5000) + XCTAssertEqual(details.mcpUsagePercent, 4) + XCTAssertEqual(details.mcpUsageUsed, 12) + XCTAssertEqual(details.mcpUsageTotal, 300) + // Weekly fields are only for the CREDIT_LIMIT-only path. + XCTAssertNil(details.weeklyUsagePercent) + XCTAssertNil(details.weeklyUsageTotal) + } + + /// Mixed response: TOKENS_LIMIT present must win over CREDIT_LIMIT items, + /// and the credit weekly window must not be populated. + func testMixedSchemaPrefersTokenWindows() async throws { + let mixedJSON = """ + {"data": {"limits": [ + {"type": "CREDIT_LIMIT", "unit": 3, "usage": 2000, "currentValue": 27, "percentage": 1}, + {"type": "CREDIT_LIMIT", "unit": 6, "usage": 10000, "currentValue": 27, "percentage": 1}, + {"type": "TOKENS_LIMIT", "total": 5000, "currentValue": 100, "percentage": 2} + ]}} + """ + let result = try await makeProvider(quotaJSON: mixedJSON).fetch() + let details = try XCTUnwrap(result.details) + XCTAssertEqual(details.tokenUsageTotal, 5000) + XCTAssertEqual(details.tokenUsagePercent, 2) + XCTAssertNil(details.weeklyUsagePercent) + XCTAssertNil(details.weeklyUsageTotal) + } + + // MARK: - Decoding + + func testCreditLimitItemsDecode() throws { + struct Envelope: Decodable { + let data: ZaiQuotaLimitResponse + } + let envelope = try JSONDecoder().decode( + Envelope.self, + from: creditOnlyJSON.data(using: .utf8)! + ) + let limits = try XCTUnwrap(envelope.data.limits) + XCTAssertEqual(limits.count, 2) + + let session = limits[0] + XCTAssertEqual(session.type, "CREDIT_LIMIT") + XCTAssertEqual(session.unit, 3) + XCTAssertEqual(session.number, 5) + XCTAssertEqual(session.usage, 2000) + XCTAssertEqual(session.currentValue, 27) + XCTAssertEqual(session.remaining, 1972) + XCTAssertEqual(session.percentage, 1) + XCTAssertNotNil(session.nextResetTime) + XCTAssertNil(session.total) + + let weekly = limits[1] + XCTAssertEqual(weekly.unit, 6) + XCTAssertEqual(weekly.number, 1) + XCTAssertEqual(weekly.usage, 10000) + XCTAssertEqual(weekly.remaining, 9972) + } + + func testCreditLimitResolvedTotalFallsBackToUsage() throws { + struct Envelope: Decodable { + let data: ZaiQuotaLimitResponse + } + let envelope = try JSONDecoder().decode( + Envelope.self, + from: creditOnlyJSON.data(using: .utf8)! + ) + let limits = try XCTUnwrap(envelope.data.limits) + // CREDIT_LIMIT has no `total`; resolvedTotal must fall back to `usage`. + XCTAssertNil(limits[0].total) + XCTAssertEqual(limits[0].resolvedTotal, 2000) + } + + func testCreditLimitComputedPercentageFallsBackToCurrentValueOverUsage() throws { + // Strip `percentage` to exercise the derivation fallback: 27/2000*100 = 1.35 + let stripped = creditOnlyJSON.replacingOccurrences(of: "\"percentage\": 1,", with: "") + struct Envelope: Decodable { + let data: ZaiQuotaLimitResponse + } + let envelope = try JSONDecoder().decode( + Envelope.self, + from: stripped.data(using: .utf8)! + ) + let limits = try XCTUnwrap(envelope.data.limits) + let computed = try XCTUnwrap(limits[0].computedPercentage) + XCTAssertEqual(computed, 1.35, accuracy: 0.001) + } + + // MARK: - Weekly propagation (status bar / change detection / hasAnyValue) + + @MainActor + func testMenuConstructionDoesNotStartProviderRefresh() { + XCTAssertNotNil(NSClassFromString("XCTestCase")) + let controller = StatusBarController(startBackgroundServices: false) + let properties = Mirror(reflecting: controller).children + for name in ["refreshTimer", "initialRefreshTask"] { + guard let property = properties.first(where: { $0.label == name }) else { + return XCTFail("Missing controller property: \(name)") + } + XCTAssertTrue(Mirror(reflecting: property.value).children.isEmpty, "\(name) started during menu construction") + } + } + + /// Weekly usage must appear in the status-bar candidate list with the + /// 7-day window priority so a Lite account shows the right top-bar window. + @MainActor + func testUsagePercentCandidatesIncludeWeeklyWithWeeklyPriority() { + let details = DetailedUsage( + tokenUsagePercent: 12, + mcpUsagePercent: 5, + weeklyUsagePercent: 2 + ) + let usage = ProviderUsage.quotaBased(remaining: 88, entitlement: 100, overagePermitted: false) + + let candidates = StatusBarController.usagePercentCandidates( + identifier: .zaiCodingPlan, + usage: usage, + details: details + ) + + let weekly = candidates.first { $0.percent == 2 } + XCTAssertNotNil(weekly, "weeklyUsagePercent must be a candidate, got: \(candidates)") + XCTAssertEqual(weekly?.priority, .weekly) + // Weekly must also win over hourly/monthly when selected. + let best = candidates.min { $0.priority.rawValue < $1.priority.rawValue } + XCTAssertEqual(best?.percent, 2) + } + + /// Weekly usage must participate in recent-quota-change detection. + @MainActor + func testUsedPercentsForChangeDetectionIncludesWeekly() { + let details = DetailedUsage( + tokenUsagePercent: 12, + weeklyUsagePercent: 2 + ) + let usage = ProviderUsage.quotaBased(remaining: 88, entitlement: 100, overagePermitted: false) + let result = ProviderResult(usage: usage, details: details) + + let percents = StatusBarController.usedPercentsForChangeDetection(identifier: .zaiCodingPlan, result: result) + XCTAssertTrue(percents.contains(2), "weeklyUsagePercent missing from change detection: \(percents)") + } + + /// The real demo/menu build path must render every active Z.AI window on + /// the top-level provider row, including a weekly-only account. + @MainActor + func testZaiTopLevelRowsRenderAllActiveWindows() { + let controller = StatusBarController(startBackgroundServices: false) + controller.loadDemoData() + + guard let menu = menu(from: controller) else { + return XCTFail("StatusBarController did not build its main menu") + } + let rows = menu.items + .compactMap { $0.attributedTitle?.string } + .filter { $0.hasPrefix(ProviderIdentifier.zaiCodingPlan.displayName) } + + XCTAssertEqual(rows.count, 2, "Expected two real Z.AI rows, got: \(rows)") + XCTAssertTrue(rows.contains { $0.contains("12%, 1%, 2%") }, "Missing token/weekly/MCP row: \(rows)") + + let weeklyOnlyRows = rows.filter { $0.contains("1%") && !$0.contains("12%") } + XCTAssertEqual(weeklyOnlyRows.count, 1, "Expected one weekly-only row: \(rows)") + if let weeklyOnlyRow = weeklyOnlyRows.first { + XCTAssertFalse(weeklyOnlyRow.contains("2%"), "Weekly-only row fabricated MCP usage: \(weeklyOnlyRows)") + } + } + + /// A Z.AI weekly detail window with a reset timestamp must render the + /// existing reset row through the shared usage-window helper. + @MainActor + func testZaiWeeklyDetailWindowRendersResetRow() { + let details = DetailedUsage( + weeklyUsagePercent: 27, + weeklyUsageReset: Date(timeIntervalSince1970: 1_787_301_777) + ) + let submenu = StatusBarController(startBackgroundServices: false).createDetailSubmenu( + details, + identifier: .zaiCodingPlan + ) + let renderedTexts = submenu.items.flatMap { item in + item.view?.subviews.compactMap { ($0 as? NSTextField)?.stringValue } ?? [] + } + + XCTAssertTrue( + renderedTexts.contains { $0.hasPrefix("Resets:") }, + "Weekly detail should render a reset row, got: \(renderedTexts)" + ) + } + + /// A details payload carrying only weekly fields must count as non-empty so + /// the detail submenu is not hidden. + func testHasAnyValueIncludesWeeklyFields() { + XCTAssertTrue(DetailedUsage(weeklyUsagePercent: 1).hasAnyValue) + XCTAssertTrue(DetailedUsage(weeklyUsageReset: Date()).hasAnyValue) + XCTAssertTrue(DetailedUsage(weeklyUsageUsed: 27).hasAnyValue) + XCTAssertTrue(DetailedUsage(weeklyUsageTotal: 10000).hasAnyValue) + XCTAssertFalse(DetailedUsage().hasAnyValue) + } + func testTransientNetworkErrorClassification() { let wrappedTimeout = NSError( domain: "ZaiCodingPlanProviderTests", diff --git a/README.md b/README.md index 81ff3d5..c5fb260 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Download the latest `.dmg` file from the [**Releases**](https://github.com/opggi | **MiniMax Coding Plan** | Quota-based | 5h/weekly quotas, Anthropic-style dual-window submenu, OpenCode auth | | **OpenCode Go** | Quota-based | 5h/weekly/monthly usage windows, model API validation, OpenCode auth | | **Grok** | Quota-based | Monthly usage, reset time, email-scoped subscription settings, local session tokens | -| **Z.AI Coding Plan** | Quota-based | Token/MCP quotas, model usage, tool usage (24h) | +| **Z.AI Coding Plan** | Quota-based | Token/MCP and Lite session/weekly quotas, model usage, tool usage (24h) | | **Brave Search** | Quota-based | Monthly search quota, reset schedule | | **Tavily** | Quota-based | Monthly search quota, plan usage | | **Synthetic** | Quota-based | 5h usage limit, request limits, reset time |