import CodexBarCore import Foundation import Testing @testable import CodexBar @Suite(.serialized) struct UsageFormatterTests { private static let usageFormatterLocalizationKeys: [String] = [ "%@ left", "Resets %@", "Resets in %@", "Resets now", "reset_tomorrow_format", "Updated %@", "Updated relative %@", "Updated absolute %@", "Updated %@h ago", "Updated %@m ago", "Updated just now", "usage_percent_suffix_left", "usage_percent_suffix_used", "byte_unit_byte", "byte_unit_bytes", "byte_unit_kilobyte", "byte_unit_kilobytes", "byte_unit_megabyte", "byte_unit_megabytes", "byte_unit_gigabyte", "byte_unit_gigabytes", ] @Test func `formats usage line`() { UsageFormatter.clearLocalizationProvider() UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: false) #expect(line == "25% left") } @Test func `formats usage line show used`() { UsageFormatter.clearLocalizationProvider() UsageFormatter.clearLocaleProvider() let line = UsageFormatter.usageLine(remaining: 25, used: 75, showUsed: true) #expect(line == "75% used") } @Test func `positive sub percent usage stays visible`() { #expect(UsageFormatter.percentString(-1) == "0%") #expect(UsageFormatter.percentString(0) == "0%") #expect(UsageFormatter.percentString(0.1) == "<1%") #expect(UsageFormatter.percentString(0.96) == "<1%") #expect(UsageFormatter.percentString(1) == "1%") #expect(UsageFormatter.percentString(101) == "100%") #expect(UsageFormatter.usageLine(remaining: 99.9, used: 0.1, showUsed: true) == "<1% used") // Values in (0.5, 1) round up to "1%" under %.0f, so the old post-format // "0%" -> "<1%" replacement missed them. percentText must show "<1%" // across the whole sub-1% range, matching percentString above. #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "<1% used") #expect(UsageFormatter.usageLine(remaining: 99.25, used: 0.75, showUsed: true) == "<1% used") #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "<1% left") let usedWindow = RateWindow(usedPercent: 0.1, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let leftWindow = RateWindow(usedPercent: 99.9, windowMinutes: nil, resetsAt: nil, resetDescription: nil) #expect(MenuBarDisplayText.percentText(window: usedWindow, showUsed: true) == "<1%") #expect(MenuBarDisplayText.percentText(window: leftWindow, showUsed: false) == "<1%") } @Test func `usage line respects injected localization provider`() { UsageFormatter.setLocalizationProvider { key in switch key { case "%.0f%% %@": "%2$@ %1$.0f%%" case "<1%% %@": "%1$@ <1%%" case "usage_percent_suffix_left": "剩余" case "usage_percent_suffix_used": "已使用" default: key } } defer { UsageFormatter.clearLocalizationProvider() } #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: false) == "剩余 22%") #expect(UsageFormatter.usageLine(remaining: 22, used: 78, showUsed: true) == "已使用 78%") #expect(UsageFormatter.usageLine(remaining: 0.75, used: 99.25, showUsed: false) == "剩余 <1%") #expect(UsageFormatter.usageLine(remaining: 99.4, used: 0.6, showUsed: true) == "已使用 <1%") } @Test func `default locale fallback matches stable en US POSIX behavior`() { UsageFormatter.clearLocalizationProvider() UsageFormatter.clearLocaleProvider() let now = Date(timeIntervalSince1970: 1_710_048_000) let old = now.addingTimeInterval(-(26 * 3600)) let defaultOutput = UsageFormatter.updatedString(from: old, now: now) UsageFormatter.setLocaleProvider { Locale(identifier: "en_US_POSIX") } let injectedStableOutput = UsageFormatter.updatedString(from: old, now: now) UsageFormatter.clearLocaleProvider() #expect(defaultOutput == injectedStableOutput) } @Test func `injected zh Hans locale applies app language formatting`() { UsageFormatter.setLocalizationProvider { key in switch key { case "Updated absolute %@": "更新于 %@" default: key } } UsageFormatter.setLocaleProvider { Locale(identifier: "zh-Hans") } defer { UsageFormatter.clearLocalizationProvider() UsageFormatter.clearLocaleProvider() } let now = Date(timeIntervalSince1970: 1_710_048_000) let old = now.addingTimeInterval(-(26 * 3600)) let output = UsageFormatter.updatedString(from: old, now: now) #expect(output.hasPrefix("更新于 ")) } @Test func `injected zh Hant relative updated string can place updated after relative time`() { UsageFormatter.setLocalizationProvider { key in switch key { case "Updated relative %@": "%@已更新" default: key } } UsageFormatter.setLocaleProvider { Locale(identifier: "zh-Hant") } defer { UsageFormatter.clearLocalizationProvider() UsageFormatter.clearLocaleProvider() } let now = Date(timeIntervalSince1970: 1_710_048_000) let old = now.addingTimeInterval(-(5 * 3600)) let output = UsageFormatter.updatedString(from: old, now: now) #expect(output.hasSuffix("已更新")) #expect(!output.hasPrefix("已更新")) } @Test func `clearing locale provider returns to stable default behavior`() { UsageFormatter.clearLocalizationProvider() UsageFormatter.clearLocaleProvider() let now = Date(timeIntervalSince1970: 1_710_048_000) let old = now.addingTimeInterval(-(26 * 3600)) let baseline = UsageFormatter.updatedString(from: old, now: now) UsageFormatter.setLocaleProvider { Locale(identifier: "fr_FR") } _ = UsageFormatter.updatedString(from: old, now: now) UsageFormatter.clearLocaleProvider() let restored = UsageFormatter.updatedString(from: old, now: now) #expect(restored == baseline) } @Test func `tomorrow reset description uses localized format`() throws { UsageFormatter.setLocalizationProvider { key in key == "reset_tomorrow_format" ? "明日 %@" : key } UsageFormatter.setLocaleProvider { Locale(identifier: "ja_JP") } defer { UsageFormatter.clearLocalizationProvider() UsageFormatter.clearLocaleProvider() } let calendar = Calendar.current let today = calendar.startOfDay(for: Date(timeIntervalSince1970: 1_750_000_000)) let now = try #require(calendar.date(byAdding: .hour, value: 12, to: today)) let tomorrow = try #require(calendar.date(byAdding: .day, value: 1, to: today)) let reset = try #require(calendar.date(byAdding: .minute, value: 10 * 60 + 50, to: tomorrow)) let output = UsageFormatter.resetDescription(from: reset, now: now) #expect(output.hasPrefix("明日 ")) #expect(!output.contains("tomorrow")) #expect(!output.contains("%@")) } @Test func `relative updated recent`() { let now = Date() let fiveHoursAgo = now.addingTimeInterval(-5 * 3600) let text = UsageFormatter.updatedString(from: fiveHoursAgo, now: now) #expect(text.hasPrefix("Updated ") || text.hasPrefix("更新")) #expect(text.contains("5")) #expect(text.lowercased().contains("ago") || text.contains("前")) } @Test func `absolute updated old`() { let now = Date() let dayAgo = now.addingTimeInterval(-26 * 3600) let text = UsageFormatter.updatedString(from: dayAgo, now: now) #expect(text.contains("Updated")) #expect(!text.contains("ago")) } @Test func `reset countdown minutes`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(10 * 60 + 1) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 11m") } @Test func `reset countdown hours and minutes`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(3 * 3600 + 31 * 60) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 3h 31m") } @Test func `reset countdown caps days with hours at two units`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval((26 * 3600) + (1 * 60)) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h") } @Test func `reset countdown days and exact hours`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(26 * 3600) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 2h") } @Test func `reset countdown days and minutes without whole hours`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval((24 * 3600) + (5 * 60)) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d 5m") } @Test func `reset countdown exact days`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(2 * 24 * 3600) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 2d") } @Test func `reset countdown rounds the last minute into a day`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval((24 * 3600) - 59) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1d") } @Test func `reset countdown exact hour`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(60 * 60) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "in 1h") } @Test func `reset countdown past date`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(-10) #expect(UsageFormatter.resetCountdownDescription(from: reset, now: now) == "now") } @Test func `reset line uses countdown when resets at is available`() { let now = Date(timeIntervalSince1970: 1_000_000) let reset = now.addingTimeInterval(10 * 60 + 1) let window = RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: reset, resetDescription: "Resets soon") let text = UsageFormatter.resetLine(for: window, style: .countdown, now: now) #expect(text == "Resets in 11m") } @Test func `reset line falls back to provided description`() { let window = RateWindow( usedPercent: 0, windowMinutes: nil, resetsAt: nil, resetDescription: "Resets at 23:30 (UTC)") let countdown = UsageFormatter.resetLine(for: window, style: .countdown) let absolute = UsageFormatter.resetLine(for: window, style: .absolute) #expect(countdown == "Resets at 23:30 (UTC)") #expect(absolute == "Resets at 23:30 (UTC)") } @Test func `model display name strips trailing dates`() { #expect(UsageFormatter.modelDisplayName("claude-opus-4-5-20251101") == "claude-opus-4-5") #expect(UsageFormatter.modelDisplayName("gpt-4o-2024-08-06") == "gpt-4o") #expect(UsageFormatter.modelDisplayName("Claude Opus 4.5 2025 1101") == "Claude Opus 4.5") #expect(UsageFormatter.modelDisplayName("claude-sonnet-4-5") == "claude-sonnet-4-5") #expect(UsageFormatter.modelDisplayName("gpt-5.3-codex-spark") == "gpt-5.3-codex-spark") #expect(UsageFormatter.modelDisplayName("unknown") == "Unknown model") } @Test func `model cost detail uses research preview label`() { #expect( UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0, totalTokens: nil) == "Research Preview") #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42, totalTokens: nil) == "$0.42") } @Test func `model cost detail includes token counts when present`() { #expect(UsageFormatter.modelCostDetail("gpt-5.2-codex", costUSD: 0.42, totalTokens: 1200) == "$0.42 · 1.2K") #expect( UsageFormatter.modelCostDetail("gpt-5.3-codex-spark", costUSD: 0, totalTokens: 1500) == "Research Preview · 1.5K") #expect(UsageFormatter.modelCostDetail("custom-model", costUSD: nil, totalTokens: 987) == "987") } @Test func `token count string formats small values without grouping`() { #expect(UsageFormatter.tokenCountString(0) == "0") #expect(UsageFormatter.tokenCountString(987) == "987") #expect(UsageFormatter.tokenCountString(-42) == "-42") } @Test func `clean plan maps O auth to ollama`() { #expect(UsageFormatter.cleanPlanName("oauth") == "Ollama") } // MARK: - Currency Formatting @Test func `currency string formats USD correctly`() { // Should produce "$54.72" without space after symbol let result = UsageFormatter.currencyString(54.72, currencyCode: "USD") #expect(result == "$54.72") #expect(!result.contains("$ ")) // No space after symbol } @Test func `currency string handles large values`() { let result = UsageFormatter.currencyString(1234.56, currencyCode: "USD") // For USD, we use direct string formatting with thousand separators #expect(result == "$1,234.56") #expect(!result.contains("$ ")) // No space after symbol } @Test func `currency string handles very large values`() { let result = UsageFormatter.currencyString(1_234_567.89, currencyCode: "USD") #expect(result == "$1,234,567.89") } @Test func `currency string handles negative values`() { // Negative sign should come before the dollar sign: -$54.72 (not $-54.72) let result = UsageFormatter.currencyString(-54.72, currencyCode: "USD") #expect(result == "-$54.72") } @Test func `currency string handles negative large values`() { let result = UsageFormatter.currencyString(-1234.56, currencyCode: "USD") #expect(result == "-$1,234.56") } @Test func `usd string matches currency string`() { // usdString should produce identical output to currencyString for USD #expect(UsageFormatter.usdString(54.72) == UsageFormatter.currencyString(54.72, currencyCode: "USD")) #expect(UsageFormatter.usdString(-1234.56) == UsageFormatter.currencyString(-1234.56, currencyCode: "USD")) #expect(UsageFormatter.usdString(0) == UsageFormatter.currencyString(0, currencyCode: "USD")) } @Test func `currency string handles zero`() { let result = UsageFormatter.currencyString(0, currencyCode: "USD") #expect(result == "$0.00") } @Test(arguments: [ (0.0, "$0"), (0.50, "$0.50"), (12.56, "$13"), (1515.0, "$1,515"), ]) func `compact currency keeps cents only below one unit`(value: Double, expected: String) { #expect(UsageFormatter.compactCurrencyString(value, currencyCode: "USD") == expected) } @Test func `currency string handles non USD currencies`() { // FormatStyle handles all currencies with proper symbols let eur = UsageFormatter.currencyString(54.72, currencyCode: "EUR") #expect(eur == "€54.72") let gbp = UsageFormatter.currencyString(54.72, currencyCode: "GBP") #expect(gbp == "£54.72") // Negative non-USD let negEur = UsageFormatter.currencyString(-1234.56, currencyCode: "EUR") #expect(negEur == "-€1,234.56") } @Test func `currency string handles small values`() { // Values smaller than 0.01 should round to $0.00 let tiny = UsageFormatter.currencyString(0.001, currencyCode: "USD") #expect(tiny == "$0.00") // Values at 0.005 should round to $0.01 (banker's rounding) let halfCent = UsageFormatter.currencyString(0.005, currencyCode: "USD") #expect(halfCent == "$0.00" || halfCent == "$0.01") // Rounding behavior may vary // One cent let oneCent = UsageFormatter.currencyString(0.01, currencyCode: "USD") #expect(oneCent == "$0.01") } @Test func `currency string handles boundary values`() { // Just under 1000 (no comma) let under1k = UsageFormatter.currencyString(999.99, currencyCode: "USD") #expect(under1k == "$999.99") // Exactly 1000 (first comma) let exact1k = UsageFormatter.currencyString(1000.00, currencyCode: "USD") #expect(exact1k == "$1,000.00") // Just over 1000 let over1k = UsageFormatter.currencyString(1000.01, currencyCode: "USD") #expect(over1k == "$1,000.01") } @Test func `credits string formats correctly`() { let result = UsageFormatter.creditsString(from: 42.5) #expect(result == "42.5 left") } @Test func `byte count string formats binary units`() { #expect(UsageFormatter.byteCountString(0) == "0 B") #expect(UsageFormatter.byteCountString(512) == "512 B") #expect(UsageFormatter.byteCountString(1536) == "1.5 KB") #expect(UsageFormatter.byteCountString(10 * 1024) == "10 KB") #expect(UsageFormatter.byteCountString(5 * 1024 * 1024) == "5 MB") #expect(UsageFormatter.byteCountString(Int64(1536 * 1024 * 1024)) == "1.5 GB") #expect(UsageFormatter.byteCountString(.min) == "-8589934592 GB") } @Test func `long byte count string localizes units and handles boundaries`() { UsageFormatter.clearLocalizationProvider() #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 megabyte") UsageFormatter.setLocalizationProvider { "[\($0)]" } defer { UsageFormatter.clearLocalizationProvider() } #expect(UsageFormatter.byteCountStringLong(1) == "1 [byte_unit_byte]") #expect(UsageFormatter.byteCountStringLong(2) == "2 [byte_unit_bytes]") #expect(UsageFormatter.byteCountStringLong(1536) == "1.5 [byte_unit_kilobytes]") #expect(UsageFormatter.byteCountStringLong(1024 * 1024) == "1 [byte_unit_megabyte]") #expect(UsageFormatter.byteCountStringLong(1024 * 1024 + 1) == "1.0 [byte_unit_megabyte]") #expect(UsageFormatter.byteCountStringLong(.min) == "-8589934592 [byte_unit_gigabytes]") } @Test func `usage formatter localization keys exist in en and zh Hans with matching placeholders`() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings") let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings") let en = try Self.readStringsTable(at: enURL) let zh = try Self.readStringsTable(at: zhURL) for key in Self.usageFormatterLocalizationKeys { let enValue = try #require(en[key], "Missing en key: \(key)") let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)") #expect( Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue), "Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'") } } private static func readStringsTable(at url: URL) throws -> [String: String] { guard let dict = NSDictionary(contentsOf: url) as? [String: String] else { throw NSError( domain: "UsageFormatterTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"]) } return dict } private static func placeholderTokens(in value: String) -> [String] { guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else { return [] } let nsRange = NSRange(value.startIndex..