514 lines
21 KiB
Swift
514 lines
21 KiB
Swift
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..<value.endIndex, in: value)
|
|
return regex
|
|
.matches(in: value, options: [], range: nsRange)
|
|
.compactMap { Range($0.range, in: value).map { String(value[$0]) } }
|
|
}
|
|
}
|