From 5d16c144a35ac8b734cc8d668c758161b88d84da Mon Sep 17 00:00:00 2001 From: dylanzonghanyang-source Date: Sat, 15 Aug 2026 01:58:17 +0800 Subject: [PATCH 1/7] fix: support CREDIT_LIMIT quotas for Z.AI Coding Plan Support the newer CREDIT_LIMIT quota schema returned by Z.AI / BigModel Coding Plan (lite tier) while preserving the existing TOKENS_LIMIT and TIME_LIMIT formats. - ZaiQuotaLimitItem: decode usage/unit; resolvedTotal falls back to usage when total is absent; computedPercentage prefers the API percentage field, else currentValue/resolvedTotal. remaining/number removed (no production consumer); server fixtures keep them to prove unknown fields do not break decoding - CREDIT_LIMIT-only responses keep BOTH rolling windows by unit (isCreditOnlySchema predicate named once): unit=3 -> 5-hour session quota (token usage fields), unit=6 -> 7-day weekly quota (new weeklyUsage* details). A single-window response maps only to its own window. Semantics verified against docs.z.ai FAQ, the Lite plan page, and third-party parsers (ClaudeBar ZaiUsageProbe, token-monitor) - Standard TOKENS_LIMIT/TIME_LIMIT behavior unchanged and wins over credit items in mixed responses - Status bar propagation: weeklyUsagePercent joins usagePercentCandidates with .weekly priority, usedPercentsForChangeDetection, and DetailedUsage.hasAnyValue; usagePercentCandidates and usedPercentsForChangeDetection made static (pure parameter functions) for direct testing - Menu: Z.AI submenu gains a Weekly (7d) window row - CLI: table shows session+weekly percentages; JSON adds weeklyUsagePercent/Used/Total/ResetsAt - Tests: provider-level fetch regressions (both windows, single window, standard schema unchanged, mixed schema), status-bar candidate priority, change detection, hasAnyValue, CLI formatters - register ZaiCodingPlanProviderTests in the Xcode project (previously present on disk but not compiled) --- .../CopilotMonitor.xcodeproj/project.pbxproj | 4 + .../App/StatusBarController.swift | 73 ++-- .../Helpers/ProviderMenuBuilder.swift | 15 + .../Models/ProviderResult.swift | 49 ++- .../Providers/ZaiCodingPlanProvider.swift | 66 +++- .../CLIFormatterTests.swift | 33 ++ .../ZaiCodingPlanProviderTests.swift | 342 ++++++++++++++++++ 7 files changed, 537 insertions(+), 45 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj index b9619f34..ec983c3a 100644 --- a/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj +++ b/CopilotMonitor/CopilotMonitor.xcodeproj/project.pbxproj @@ -90,6 +90,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 */; }; + ZAITESTBF1111111111111 /* ZaiCodingPlanProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ZAITESTFR1111111111111 /* ZaiCodingPlanProviderTests.swift */; }; TOKENTESTBF1111111111111 /* TokenManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TOKENTESTFR1111111111111 /* TokenManagerTests.swift */; }; CODEXTESTBF111111111111 /* CodexProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CODEXTESTFR111111111111 /* CodexProviderTests.swift */; }; OCAUTHTESTBF11111111111 /* OpenCodeAuthDecodingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */; }; @@ -233,6 +234,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 = ""; }; + ZAITESTFR1111111111111 /* ZaiCodingPlanProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZaiCodingPlanProviderTests.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 = ""; }; OCAUTHTESTFR11111111111 /* OpenCodeAuthDecodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCodeAuthDecodingTests.swift; sourceTree = ""; }; @@ -455,6 +457,7 @@ 54353FD130DDE0500F6B367F /* MenuResultBuilderTests.swift */, SYNTHTEST1111111111111111 /* SyntheticProviderTests.swift */, NANOGPTTESTFR1111111111 /* NanoGptProviderTests.swift */, + ZAITESTFR1111111111111 /* ZaiCodingPlanProviderTests.swift */, MINIMAXTESTFR11111111111 /* MiniMaxProviderTests.swift */, OCGOTESTFR11111111111 /* OpenCodeGoProviderTests.swift */, GROKTESTFR11111111111 /* GrokProviderTests.swift */, @@ -715,6 +718,7 @@ B58BAD3BFD97973070A2A892 /* MenuResultBuilderTests.swift in Sources */, SYNTHTEST2222222222222222 /* SyntheticProviderTests.swift in Sources */, NANOGPTTESTBF1111111111 /* NanoGptProviderTests.swift in Sources */, + ZAITESTBF1111111111111 /* ZaiCodingPlanProviderTests.swift in Sources */, MINIMAXTESTBF11111111111 /* MiniMaxProviderTests.swift in Sources */, OCGOTESTBF11111111111 /* OpenCodeGoProviderTests.swift in Sources */, GROKTESTBF11111111111 /* GrokProviderTests.swift in Sources */, diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index 765f0221..b8587768 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 } @@ -917,12 +917,12 @@ final class StatusBarController: NSObject { return ProviderIdentifier.allCases.first(where: { isProviderEnabled($0) }) } - private func normalizedUsagePercent(_ percent: Double?) -> Double? { + private static func normalizedUsagePercent(_ percent: Double?) -> Double? { guard let percent, percent.isFinite else { return nil } return min(max(percent, 0), 999) } - private func dailyPercentFromDetails(_ details: DetailedUsage?) -> Double? { + private static func dailyPercentFromDetails(_ details: DetailedUsage?) -> Double? { guard let details else { return nil } if let limit = details.limit, limit > 0, let used = details.dailyUsage { return (used / limit) * 100.0 @@ -930,7 +930,7 @@ final class StatusBarController: NSObject { return details.dailyUsage } - private func priorityForWindowHours( + private static func priorityForWindowHours( _ hours: Int?, fallback: UsageDisplayWindowPriority ) -> UsageDisplayWindowPriority { @@ -941,7 +941,7 @@ final class StatusBarController: NSObject { return .hourly } - private func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? { + private static func chutesMonthlyPercentFromDetails(_ details: DetailedUsage?) -> Double? { guard let details else { return nil } let configuredPlan = SubscriptionSettingsManager.shared.getPlan(for: .chutes) @@ -959,14 +959,27 @@ 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). + static func zaiCodingPlanTopLevelPercents(details: DetailedUsage?) -> [Double] { + [ + details?.tokenUsagePercent, + details?.weeklyUsagePercent, + details?.mcpUsagePercent + ].compactMap { $0 } + } + + static func usagePercentCandidates( identifier: ProviderIdentifier, usage: ProviderUsage, details: DetailedUsage? ) -> [UsagePercentCandidate] { var candidates: [UsagePercentCandidate] = [] func add(_ percent: Double?, priority: UsageDisplayWindowPriority) { - guard let normalized = normalizedUsagePercent(percent) else { return } + guard let normalized = Self.normalizedUsagePercent(percent) else { return } candidates.append(UsagePercentCandidate(percent: normalized, priority: priority)) } @@ -995,19 +1008,19 @@ final class StatusBarController: NSObject { case .codex: add( details?.secondaryUsage, - priority: priorityForWindowHours(details?.codexSecondaryWindowHours, fallback: .weekly) + priority: Self.priorityForWindowHours(details?.codexSecondaryWindowHours, fallback: .weekly) ) add( details?.sparkSecondaryUsage, - priority: priorityForWindowHours(details?.sparkSecondaryWindowHours, fallback: .weekly) + priority: Self.priorityForWindowHours(details?.sparkSecondaryWindowHours, fallback: .weekly) ) add( - dailyPercentFromDetails(details), - priority: priorityForWindowHours(details?.codexPrimaryWindowHours, fallback: .daily) + Self.dailyPercentFromDetails(details), + priority: Self.priorityForWindowHours(details?.codexPrimaryWindowHours, fallback: .daily) ) add( details?.sparkUsage, - priority: priorityForWindowHours(details?.sparkPrimaryWindowHours, fallback: .hourly) + priority: Self.priorityForWindowHours(details?.sparkPrimaryWindowHours, fallback: .hourly) ) case .commandCode: add(usage.usagePercentage, priority: .monthly) @@ -1024,11 +1037,12 @@ 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: - add(chutesMonthlyPercentFromDetails(details), priority: .monthly) - add(dailyPercentFromDetails(details), priority: .daily) + add(Self.chutesMonthlyPercentFromDetails(details), priority: .monthly) + add(Self.dailyPercentFromDetails(details), priority: .daily) case .synthetic: add(details?.fiveHourUsage, priority: .hourly) case .tavilySearch, .braveSearch: @@ -1046,7 +1060,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 +1082,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 +1091,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) ) } } @@ -1085,7 +1099,7 @@ final class StatusBarController: NSObject { // Gemini CLI special case: add as fallback priority since these don't have window metadata if identifier == .geminiCLI, let geminiAccounts = result.details?.geminiAccounts { for account in geminiAccounts { - if let normalized = normalizedUsagePercent(100.0 - account.remainingPercentage) { + if let normalized = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) { allCandidates.append(UsagePercentCandidate(percent: normalized, priority: .fallback)) } } @@ -1103,12 +1117,12 @@ 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?) { guard case .quotaBased = usage else { return } - if let percent = normalizedUsagePercent(usage.usagePercentage) { + if let percent = Self.normalizedUsagePercent(usage.usagePercentage) { usedPercents.append(percent) } @@ -1126,10 +1140,11 @@ final class StatusBarController: NSObject { details.cursorApiUsage, details.tokenUsagePercent, details.mcpUsagePercent, + details.weeklyUsagePercent, details.openCodeGoMonthlyUsage ] for percent in extraPercents { - if let normalized = normalizedUsagePercent(percent) { + if let normalized = Self.normalizedUsagePercent(percent) { usedPercents.append(normalized) } } @@ -1146,7 +1161,7 @@ final class StatusBarController: NSObject { if identifier == .geminiCLI, let geminiAccounts = result.details?.geminiAccounts { for account in geminiAccounts { - if let percent = normalizedUsagePercent(100.0 - account.remainingPercentage) { + if let percent = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) { usedPercents.append(percent) } } @@ -1163,7 +1178,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) @@ -2136,10 +2151,10 @@ 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 = [dailyPercentFromDetails(account.details), chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 } + let percents = [Self.dailyPercentFromDetails(account.details), Self.chutesMonthlyPercentFromDetails(account.details)].compactMap { $0 } usedPercents = percents.isEmpty ? [account.usage.usagePercentage] : percents } else if identifier == .nanoGpt { let percents = [ @@ -2222,10 +2237,10 @@ 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 = [dailyPercentFromDetails(result.details), chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 } + let percents = [Self.dailyPercentFromDetails(result.details), Self.chutesMonthlyPercentFromDetails(result.details)].compactMap { $0 } usedPercents = percents.isEmpty ? [singlePercent] : percents } else if identifier == .nanoGpt { let percents = [ @@ -2304,7 +2319,7 @@ final class StatusBarController: NSObject { for account in geminiAccounts { hasQuota = true let accountNumber = account.accountIndex + 1 - let usedPercent = normalizedUsagePercent(100.0 - account.remainingPercentage) ?? 0.0 + let usedPercent = Self.normalizedUsagePercent(100.0 - account.remainingPercentage) ?? 0.0 // Gemini account rows should represent Gemini quota only. // Antigravity has its own provider row and should not be duplicated here. let usedPercents: [Double] = [usedPercent] diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 3565a076..43ae861f 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -732,6 +732,21 @@ 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, + 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 dc4e6777..989cac94 100644 --- a/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift +++ b/CopilotMonitor/CopilotMonitor/Models/ProviderResult.swift @@ -207,6 +207,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? @@ -295,6 +301,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, @@ -378,6 +388,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 @@ -415,6 +429,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 @@ -491,6 +506,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) @@ -577,6 +596,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) @@ -704,7 +727,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 @@ -712,6 +735,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 { @@ -999,10 +1035,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: ",") } } @@ -1456,6 +1496,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 97ba0dfa..ff0b1558 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -7,20 +7,36 @@ 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` and leftover as `remaining` + /// instead of `total`/`currentValue` — keep `usage` so credit-based plans can render. + let usage: 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 +45,8 @@ private struct ZaiQuotaLimitItem: Decodable { case currentValue case total case nextResetTime + case usage + case unit } init(from decoder: Decoder) throws { @@ -38,6 +56,8 @@ 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) } private static func decodeDouble(_ container: KeyedDecodingContainer, forKey key: CodingKeys) -> Double? { @@ -158,16 +178,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") } @@ -178,18 +201,33 @@ 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, and the + // plan's two rolling windows are distinguished by `unit`: + // unit=3 (hours) -> 5-hour session quota + // unit=6 (weeks) -> 7-day weekly quota + // (verified against docs.z.ai FAQ + subscription page + third-party + // parsers). Keep BOTH windows; the weekly cap is the one users care about. + let creditLimits = limits.filter { $0.type.uppercased() == "CREDIT_LIMIT" } + let isCreditOnlySchema = tokenLimit == nil && mcpLimit == nil + let creditSessionLimit = isCreditOnlySchema ? creditLimits.first { $0.unit == 3 } : nil + let creditWeeklyLimit = isCreditOnlySchema ? creditLimits.first { $0.unit == 6 } : 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( @@ -225,13 +263,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 f9bde6c0..f5a5ba94 100644 --- a/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/CLIFormatterTests.swift @@ -428,4 +428,37 @@ 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)") + } } diff --git a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index bebf3d2b..b1e51334 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -2,6 +2,46 @@ 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) + } + + override func tearDown() { + MockURLProtocol.requestHandler = nil + super.tearDown() + } func testProviderIdentifier() { let provider = ZaiCodingPlanProvider() @@ -12,4 +52,306 @@ final class ZaiCodingPlanProviderTests: XCTestCase { let provider = ZaiCodingPlanProvider() 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) + + // 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) + } + + // 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.usage, 2000) + XCTAssertEqual(session.currentValue, 27) + XCTAssertEqual(session.percentage, 1) + XCTAssertNotNil(session.nextResetTime) + XCTAssertNil(session.total) + + let weekly = limits[1] + XCTAssertEqual(weekly.unit, 6) + XCTAssertEqual(weekly.usage, 10000) + } + + 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) + + /// 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)") + } + + /// 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) + } + + // MARK: - Top-level quota row windows + + /// The Z.AI top-level quota row must include the Lite weekly window + /// alongside token (5h session) and MCP, in window-length order. + @MainActor + func testZaiTopLevelPercentsIncludeWeekly() { + let details = DetailedUsage( + tokenUsagePercent: 12, + mcpUsagePercent: 2, + weeklyUsagePercent: 1 + ) + XCTAssertEqual( + StatusBarController.zaiCodingPlanTopLevelPercents(details: details), + [12, 1, 2] + ) + } + + /// A plan with only the weekly window must still render it on the row. + @MainActor + func testZaiTopLevelPercentsWeeklyOnly() { + let details = DetailedUsage(weeklyUsagePercent: 1) + XCTAssertEqual( + StatusBarController.zaiCodingPlanTopLevelPercents(details: details), + [1] + ) + } + + /// No populated window -> empty array (caller falls back to overall %). + @MainActor + func testZaiTopLevelPercentsEmptyWithoutWindows() { + XCTAssertEqual( + StatusBarController.zaiCodingPlanTopLevelPercents(details: nil), + [] + ) + } + } From 4516b4248d4c57eea414aaa873e6234d4a123c28 Mon Sep 17 00:00:00 2001 From: dylanzonghanyang-source Date: Sat, 15 Aug 2026 12:51:25 +0800 Subject: [PATCH 2/7] test: cover rendered Z.AI weekly quota rows --- .../App/StatusBarController.swift | 41 ++++++---- .../ZaiCodingPlanProviderTests.swift | 82 +++++++++++-------- 2 files changed, 73 insertions(+), 50 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index b8587768..eadcbb8d 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -964,7 +964,7 @@ final class StatusBarController: NSObject { /// 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). - static func zaiCodingPlanTopLevelPercents(details: DetailedUsage?) -> [Double] { + private static func zaiCodingPlanTopLevelPercents(details: DetailedUsage?) -> [Double] { [ details?.tokenUsagePercent, details?.weeklyUsagePercent, @@ -4263,22 +4263,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, + weeklyUsagePercent: 1.0, + mcpUsagePercent: 2.0, tokenUsageReset: oneDayFromNow, - tokenUsageUsed: 990_000, - tokenUsageTotal: 1_000_000, - mcpUsagePercent: 45.0, - mcpUsageReset: oneDayFromNow, - mcpUsageUsed: 45, - mcpUsageTotal: 100, - modelUsageTokens: 500_000, - modelUsageCalls: 128, - toolNetworkSearchCount: 42, - toolWebReadCount: 15, - toolZreadCount: 8 - ) + weeklyUsageReset: sevenDaysFromNow, + mcpUsageReset: oneDayFromNow + ), + accounts: [ + ProviderAccountResult( + accountIndex: 0, + accountId: "zai-session", + usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false), + details: DetailedUsage( + tokenUsagePercent: 12.0, + weeklyUsagePercent: 1.0, + mcpUsagePercent: 2.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/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index b1e51334..b145a7c1 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -38,6 +38,29 @@ final class ZaiCodingPlanProviderTests: XCTestCase { 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() @@ -308,6 +331,30 @@ final class ZaiCodingPlanProviderTests: XCTestCase { 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() + controller.loadDemoData() + + guard let menu = menu(from: controller) else { + return XCTFail("StatusBarController did not build its main menu") + } + let rows = menu.items + .map(\.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 details payload carrying only weekly fields must count as non-empty so /// the detail submenu is not hidden. func testHasAnyValueIncludesWeeklyFields() { @@ -318,40 +365,5 @@ final class ZaiCodingPlanProviderTests: XCTestCase { XCTAssertFalse(DetailedUsage().hasAnyValue) } - // MARK: - Top-level quota row windows - - /// The Z.AI top-level quota row must include the Lite weekly window - /// alongside token (5h session) and MCP, in window-length order. - @MainActor - func testZaiTopLevelPercentsIncludeWeekly() { - let details = DetailedUsage( - tokenUsagePercent: 12, - mcpUsagePercent: 2, - weeklyUsagePercent: 1 - ) - XCTAssertEqual( - StatusBarController.zaiCodingPlanTopLevelPercents(details: details), - [12, 1, 2] - ) - } - - /// A plan with only the weekly window must still render it on the row. - @MainActor - func testZaiTopLevelPercentsWeeklyOnly() { - let details = DetailedUsage(weeklyUsagePercent: 1) - XCTAssertEqual( - StatusBarController.zaiCodingPlanTopLevelPercents(details: details), - [1] - ) - } - - /// No populated window -> empty array (caller falls back to overall %). - @MainActor - func testZaiTopLevelPercentsEmptyWithoutWindows() { - XCTAssertEqual( - StatusBarController.zaiCodingPlanTopLevelPercents(details: nil), - [] - ) - } } From a43e1f7942d44a172e4c789e812ff9c94071f9dd Mon Sep 17 00:00:00 2001 From: dylanzonghanyang-source Date: Sat, 15 Aug 2026 12:55:52 +0800 Subject: [PATCH 3/7] fix: order Z.AI demo fixture arguments --- .../CopilotMonitor/App/StatusBarController.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift index eadcbb8d..3e6a35df 100644 --- a/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift +++ b/CopilotMonitor/CopilotMonitor/App/StatusBarController.swift @@ -4266,11 +4266,11 @@ extension StatusBarController { usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false), details: DetailedUsage( tokenUsagePercent: 12.0, - weeklyUsagePercent: 1.0, - mcpUsagePercent: 2.0, tokenUsageReset: oneDayFromNow, - weeklyUsageReset: sevenDaysFromNow, - mcpUsageReset: oneDayFromNow + mcpUsagePercent: 2.0, + mcpUsageReset: oneDayFromNow, + weeklyUsagePercent: 1.0, + weeklyUsageReset: sevenDaysFromNow ), accounts: [ ProviderAccountResult( @@ -4279,8 +4279,8 @@ extension StatusBarController { usage: .quotaBased(remaining: 88, entitlement: 100, overagePermitted: false), details: DetailedUsage( tokenUsagePercent: 12.0, - weeklyUsagePercent: 1.0, - mcpUsagePercent: 2.0 + mcpUsagePercent: 2.0, + weeklyUsagePercent: 1.0 ) ), ProviderAccountResult( From 437835ec5375d45e7f93d0c934bf5817049db586 Mon Sep 17 00:00:00 2001 From: dylanzonghanyang-source Date: Sat, 15 Aug 2026 13:39:13 +0800 Subject: [PATCH 4/7] test: fix Z.AI row regression coverage build --- .../CopilotMonitorTests/ZaiCodingPlanProviderTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index b145a7c1..b9b7eae6 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -342,7 +342,7 @@ final class ZaiCodingPlanProviderTests: XCTestCase { return XCTFail("StatusBarController did not build its main menu") } let rows = menu.items - .map(\.attributedTitle.string) + .compactMap { $0.attributedTitle?.string } .filter { $0.hasPrefix(ProviderIdentifier.zaiCodingPlan.displayName) } XCTAssertEqual(rows.count, 2, "Expected two real Z.AI rows, got: \(rows)") @@ -365,5 +365,4 @@ final class ZaiCodingPlanProviderTests: XCTestCase { XCTAssertFalse(DetailedUsage().hasAnyValue) } - } From 2eeee240c5443f025b82a3554f0013e87a8160cc Mon Sep 17 00:00:00 2001 From: dylanzonghanyang-source Date: Sat, 15 Aug 2026 13:56:41 +0800 Subject: [PATCH 5/7] test: avoid modal prompt in Z.AI menu regression --- .../ZaiCodingPlanProviderTests.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index b9b7eae6..536938f1 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -335,6 +335,17 @@ final class ZaiCodingPlanProviderTests: XCTestCase { /// the top-level provider row, including a weekly-only account. @MainActor func testZaiTopLevelRowsRenderAllActiveWindows() { + let githubStarPromptKey = "githubStarPromptDismissed" + let previousPromptValue = UserDefaults.standard.object(forKey: githubStarPromptKey) + UserDefaults.standard.set(true, forKey: githubStarPromptKey) + defer { + if let previousPromptValue { + UserDefaults.standard.set(previousPromptValue, forKey: githubStarPromptKey) + } else { + UserDefaults.standard.removeObject(forKey: githubStarPromptKey) + } + } + let controller = StatusBarController() controller.loadDemoData() From e437791c1679c8e940b86ecff7f4ba6bdd364baf Mon Sep 17 00:00:00 2001 From: dylanzonghanyang-source Date: Sat, 15 Aug 2026 15:54:00 +0800 Subject: [PATCH 6/7] fix: validate Z.AI credit window duration --- .../Providers/ZaiCodingPlanProvider.swift | 33 +++++--- .../ZaiCodingPlanProviderTests.swift | 76 +++++++++++++++++++ 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift index ff0b1558..eb93c2de 100644 --- a/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift +++ b/CopilotMonitor/CopilotMonitor/Providers/ZaiCodingPlanProvider.swift @@ -17,9 +17,13 @@ struct ZaiQuotaLimitItem: Decodable { let currentValue: Int? let total: Int? let nextResetTime: Int64? - /// CREDIT_LIMIT items (lite tier) report capacity as `usage` and leftover as `remaining` - /// instead of `total`/`currentValue` — keep `usage` so credit-based plans can render. + /// 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). @@ -47,6 +51,8 @@ struct ZaiQuotaLimitItem: Decodable { case nextResetTime case usage case unit + case number + case remaining } init(from decoder: Decoder) throws { @@ -58,6 +64,8 @@ struct ZaiQuotaLimitItem: Decodable { 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? { @@ -206,16 +214,21 @@ final class ZaiCodingPlanProvider: ProviderProtocol { 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, and the - // plan's two rolling windows are distinguished by `unit`: - // unit=3 (hours) -> 5-hour session quota - // unit=6 (weeks) -> 7-day weekly quota - // (verified against docs.z.ai FAQ + subscription page + third-party - // parsers). Keep BOTH windows; the weekly cap is the one users care about. + // 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 } : nil - let creditWeeklyLimit = isCreditOnlySchema ? creditLimits.first { $0.unit == 6 } : 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 diff --git a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index 536938f1..ffa10788 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -163,6 +163,8 @@ final class ZaiCodingPlanProviderTests: XCTestCase { 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) @@ -190,6 +192,76 @@ final class ZaiCodingPlanProviderTests: XCTestCase { 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 @@ -250,15 +322,19 @@ final class ZaiCodingPlanProviderTests: XCTestCase { 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 { From 3bbf2663e1596e81a9800291f7d845a9ed7e2c19 Mon Sep 17 00:00:00 2001 From: dylanzonghanyang-source Date: Sat, 15 Aug 2026 16:13:36 +0800 Subject: [PATCH 7/7] fix: render Z.AI weekly quota reset --- .../Helpers/ProviderMenuBuilder.swift | 1 + .../ZaiCodingPlanProviderTests.swift | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift index 43ae861f..002dfcbe 100644 --- a/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift +++ b/CopilotMonitor/CopilotMonitor/Helpers/ProviderMenuBuilder.swift @@ -738,6 +738,7 @@ extension StatusBarController { label: "Weekly (7d)", usagePercent: weeklyUsage, resetDate: details.weeklyUsageReset, + windowHours: 24 * 7, isMonthly: false ) items.forEach { submenu.addItem($0) } diff --git a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift index ffa10788..ab44405d 100644 --- a/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift +++ b/CopilotMonitor/CopilotMonitorTests/ZaiCodingPlanProviderTests.swift @@ -442,6 +442,28 @@ final class ZaiCodingPlanProviderTests: XCTestCase { } } + /// 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().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() {