feat: add daily fiat and crypto currency conversion

This commit is contained in:
2026-10-13 21:31:14 +02:00
parent a57cbc219c
commit d3f9ec92d7
5 changed files with 459 additions and 13 deletions
+16 -6
View File
@@ -30,7 +30,7 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to
## Controls
- Type to filter applications, calculate an expression, or convert units; the first result is selected automatically.
- Type to filter applications, calculate an expression, or convert units and currencies; the first result is selected automatically.
- Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text.
- Return, keypad Enter, the footer button, or a double-click opens the selected application or copies a calculator answer and dismisses the launcher.
- Command-K or the bottom-right Actions button opens a searchable selector for the selected application. **Open** is first and selected by default. Up/Down or Control-K/Control-J selects an action; Return executes it. Escape closes the selector and returns focus to search.
@@ -75,9 +75,9 @@ Background discovery preserves the selected application even if its row changes.
Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes.
## Calculator and unit conversion
## Calculator, unit and currency conversion
Type directly into search, as with [Raycast's calculator](https://manual.raycast.com/calculator). A recognized expression or conversion replaces application results with one **Calculator** row. Return, keypad Enter, double-click, or **Copy Answer** copies the displayed answer and dismisses the launcher, restoring the previously active application. Conversion answers include the destination unit. Application actions are disabled for calculator results; calculations do not affect launch ranking.
Type directly into search, as with [Raycast's calculator](https://manual.raycast.com/calculator). A recognized expression or conversion replaces application results with one **Calculator** or **Daily rates** row. Return, keypad Enter, double-click, or **Copy Answer** copies the displayed answer and dismisses the launcher, restoring the previously active application. Conversion answers include the destination unit or currency code. Application actions are disabled for calculator results; calculations do not affect launch ranking.
Arithmetic supports `+`, `-`, `*` (also `x` or `×`), `/` (also `÷`), parentheses, scientific notation, and right-associative `^` powers. Powers bind more tightly than unary signs: `-2^2` is `-4`, while `(-2)^2` is `4`. Functions are `sqrt`, `abs`, `round`, `floor`, `ceil`, `sin`, `cos`, `tan`, `log` (base ten), and `ln`; use parentheses around their arguments. Trigonometric arguments are radians; constants are `pi`/`π` and `e`. A trailing `%` divides by 100; `%` before an unsigned operand is remainder, and `mod` accepts signed operands. Percentage queries such as `52% of 900` and `20% off 80` also work.
@@ -96,9 +96,19 @@ Convert with `<expression> <source unit> to <target unit>`; `in` and `as` also w
Data abbreviations distinguish lowercase `b` (bits) from uppercase `B` (bytes): `8b to B` gives `1 B`. Decimal `KB` is 1,000 bytes; binary `KiB` is 1,024. Culinary volumes use US customary measures, not imperial measures or the 240 mL legal cup.
All calculation is offline, with no added dependencies or script evaluation. Output uses a decimal point and up to 12 significant digits; physical units generally use Foundation's native conversion precision, with exact defining Fahrenheit ratios and consistent US customary volume subdivisions. Division by zero, invalid function domains, nonfinite results, and incompatible unit families show a non-copyable error. Ordinary app names, standalone numeric literals, incomplete expressions, and unsupported syntax remain application searches. Input is bounded to 512 UTF-8 bytes and recursive parser depth below 16 to keep live search safe.
Arithmetic and physical/data unit conversion are offline, with no added dependencies or script evaluation. Output uses a decimal point and up to 12 significant digits; physical units generally use Foundation's native conversion precision, with exact defining Fahrenheit ratios and consistent US customary volume subdivisions. Division by zero, invalid function domains, nonfinite results, and incompatible unit families show a non-copyable error. Ordinary app names, standalone numeric literals, incomplete expressions, and unsupported syntax remain application searches. Input is bounded to 512 UTF-8 bytes and recursive parser depth below 16 to keep live search safe.
This is arithmetic and physical/data unit conversion, not full Raycast parity: currency rates, calendar/date/time-zone calculations, design units, and unrestricted natural-language math are not supported.
### Daily currency rates
Convert traditional currencies and crypto with currency codes, for example `100 USD to DKK`, `50 eur in usd`, `0.5 BTC to ETH`, or `(100 + 25) GBP as EUR`. Codes are case-insensitive; `to`, `in`, and `as` work just like unit conversions. Answers include the destination code when copied. Supported codes depend on the provider's available rates; unsupported currency codes show a non-copyable error rather than an invented rate.
Rates come from the community-maintained [currency-api](https://github.com/fawazahmed0/exchange-api), using its daily EUR-based snapshot for both fiat and crypto conversions. These are **daily rates, not live market prices**. The provider's actual snapshot date is shown in the result detail; it is a data date, not a market quote timestamp. Treat the result as an estimate, not a guaranteed trading or payment quote.
A complete, recognized currency query fetches rates over the internet without an account or API key. While loading, there is no copyable answer. Conjure keeps one snapshot in memory and reuses it across queries, with at most one fetch in flight. The next recognized currency query after the local day changes refreshes the cache; there is no background polling or fetch per keystroke. Cached answers keep their snapshot date visible, and an older cache is marked stale while refreshing. The cache is not saved across app restarts.
If fetching fails, Conjure shows a non-copyable currency error instead of silently using old or fake rates; any retained snapshot date remains visible. Change the query to retry. Application searches, arithmetic, and physical/data unit conversions still work offline.
This is not full Raycast parity: calendar/date/time-zone calculations, design units, and unrestricted natural-language math are not supported.
## Application actions and uninstall
@@ -137,7 +147,7 @@ The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView`
AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection.
- `Sources/Conjure`: application lifecycle, launcher, Settings, contextual actions and uninstall review windows, and application launching.
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, calculator and unit conversion, global shortcut registration, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery.
- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, calculator and unit/currency conversion, global shortcut registration, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery.
- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, calculator precedence/conversion/error boundaries, shortcut validation, alignment boundaries, installation safety, and uninstall failure/recovery checks.
The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)).
+69 -7
View File
@@ -13,6 +13,12 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
private var applications: [Application] = []
private var results: [Application] = []
private var calculation: CalculatorResult?
private var isCurrencyCalculation = false
private var currencyRates: CurrencyRates?
private var currencyFetchedAt: Date?
private var currencyTask: Task<Void, Never>?
private var currencyError: String?
private var currencyFailureQuery: String?
private var locationLabels: [URL: String] = [:]
private var discoveryTask: Task<Void, Never>?
private var isLaunching = false
@@ -73,6 +79,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
fatalError("Conjure creates its window programmatically")
}
deinit {
currencyTask?.cancel()
}
func present() {
if launcherFrameBeforeReview != nil {
showWindow(nil)
@@ -181,7 +191,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
let searchFont = NSFont.systemFont(ofSize: 20)
searchField.font = searchFont
searchField.placeholderAttributedString = NSAttributedString(
string: "Search apps, calculate, or convert units…",
string: "Search apps, calculate, or convert…",
attributes: [.foregroundColor: SunsetPalette.secondaryText, .font: searchFont]
)
searchField.textColor = SunsetPalette.text
@@ -195,7 +205,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
searchField.sendsSearchStringImmediately = true
searchField.sendsWholeSearchString = false
searchField.delegate = self
searchField.setAccessibilityLabel("Search applications, calculate, or convert units")
searchField.setAccessibilityLabel("Search applications, calculate, or convert units and currencies")
let symbol = NSImageView()
symbol.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)
@@ -348,6 +358,28 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
private func refreshCurrencyRates() {
guard currencyTask == nil else { return }
currencyTask = Task { [weak self] in
let result: Result<CurrencyRates, any Error>
do { result = .success(try await CurrencyAPI.fetch()) }
catch { result = .failure(error) }
guard !Task.isCancelled, let self else { return }
self.currencyTask = nil
switch result {
case .success(let rates):
self.currencyRates = rates
self.currencyFetchedAt = Date()
self.currencyError = nil
self.currencyFailureQuery = nil
case .failure(let error):
self.currencyError = error.localizedDescription
self.currencyFailureQuery = self.searchField.stringValue
}
self.updateResults(preservingSelection: true, allowingCurrencyFetch: false)
}
}
private func updateLocationLabels() {
var nameCounts: [String: Int] = [:]
nameCounts.reserveCapacity(applications.count)
@@ -370,10 +402,37 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
}
}
private func updateResults(preservingSelection: Bool = false) {
private func updateResults(preservingSelection: Bool = false, allowingCurrencyFetch: Bool = true) {
let selectedURL = preservingSelection && results.indices.contains(tableView.selectedRow)
? results[tableView.selectedRow].id : nil
calculation = Calculator.evaluate(searchField.stringValue)
let query = searchField.stringValue
if currencyFailureQuery != query {
currencyError = nil
currencyFailureQuery = nil
}
isCurrencyCalculation = Calculator.isCurrencyQuery(query, currencyRates: currencyRates)
let needsCurrencyRefresh = isCurrencyCalculation && (currencyFetchedAt.map {
!Calendar.current.isDate($0, inSameDayAs: Date())
} ?? true)
if allowingCurrencyFetch, needsCurrencyRefresh, currencyError == nil {
refreshCurrencyRates()
}
calculation = Calculator.evaluate(query, currencyRates: currencyRates)
if isCurrencyCalculation, let currencyError {
let cached = currencyRates.map { " Cached snapshot \($0.date) is not used." } ?? ""
calculation = CalculatorResult(
displayValue: "Currency rates unavailable", copyValue: nil,
detail: "Daily rates: \(currencyError). Change the query to retry.\(cached)"
)
} else if isCurrencyCalculation, currencyRates != nil, let answer = calculation {
let cacheStatus = needsCurrencyRefresh
? (currencyTask == nil ? "Stale cached daily rates" : "Stale cache; refreshing daily rates…")
: "Cached daily rates"
calculation = CalculatorResult(
displayValue: answer.displayValue, copyValue: answer.copyValue,
detail: "\(answer.detail) · \(cacheStatus)"
)
}
if calculation != nil {
results = []
} else if searchField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
@@ -381,8 +440,10 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
} else {
results = history.results(for: searchField.stringValue, in: applications)
}
sectionLabel.stringValue = calculation == nil ? "Applications" : "Calculator"
tableView.setAccessibilityLabel(calculation == nil ? "Applications" : "Calculator result")
sectionLabel.stringValue = isCurrencyCalculation ? "Daily currency rates"
: calculation == nil ? "Applications" : "Calculator"
tableView.setAccessibilityLabel(isCurrencyCalculation ? "Daily currency conversion result"
: calculation == nil ? "Applications" : "Calculator result")
tableView.reloadData()
if resultCount > 0 {
let row = selectedURL.flatMap { url in results.firstIndex { $0.id == url } } ?? 0
@@ -457,7 +518,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if let calculation {
cell.textField?.stringValue = calculation.displayValue
cell.locationLabel.isHidden = true
cell.kindLabel.stringValue = calculation.copyValue == nil ? "Error" : "Calculator"
cell.kindLabel.stringValue = isCurrencyCalculation ? "Daily rates"
: calculation.copyValue == nil ? "Error" : "Calculator"
cell.imageView?.image = NSImage(systemSymbolName: "equal", accessibilityDescription: nil)?
.withSymbolConfiguration(.init(pointSize: LauncherRowView.iconSize, weight: .regular))
cell.imageView?.contentTintColor = SunsetPalette.accent
+77
View File
@@ -4,6 +4,12 @@ public struct CalculatorResult: Sendable {
public let displayValue: String
public let copyValue: String?
public let detail: String
public init(displayValue: String, copyValue: String?, detail: String) {
self.displayValue = displayValue
self.copyValue = copyValue
self.detail = detail
}
}
public enum Calculator {
@@ -15,9 +21,34 @@ public enum Calculator {
private static let maximumDepth = 16
public static func evaluate(_ query: String) -> CalculatorResult? {
evaluate(query, currencyRates: nil)
}
public static func isCurrencyQuery(_ query: String, currencyRates: CurrencyRates? = nil) -> Bool {
currencyQuery(query, rates: currencyRates) != nil
}
public static func evaluate(_ query: String, currencyRates: CurrencyRates?) -> CalculatorResult? {
guard query.utf8.count <= 512 else { return nil }
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return nil }
if let currency = currencyQuery(query, rates: currencyRates) {
let detail = "\(currency.expression) \(currency.source.uppercased()) → \(currency.target.uppercased())"
if let error = currency.amount.error { return failure(error, detail: detail) }
guard let currencyRates else { return failure("Loading currency rates…", detail: detail) }
let datedDetail = "\(detail) · Daily snapshot \(currencyRates.date)"
guard let source = currencyRates.rates[currency.source],
let target = currencyRates.rates[currency.target] else {
return failure("Unsupported currency", detail: datedDetail)
}
guard source.isFinite, source > 0, target.isFinite, target > 0 else {
return failure("Invalid currency rates", detail: datedDetail)
}
let value = currency.amount.value * target / source
guard value.isFinite else { return failure("Result is not finite", detail: datedDetail) }
let text = "\(format(value)) \(currency.target.uppercased())"
return CalculatorResult(displayValue: text, copyValue: text, detail: datedDetail)
}
let words = query.split(whereSeparator: \.isWhitespace).map(String.init)
if words.count >= 3 {
for separator in 1..<(words.count - 1)
@@ -57,6 +88,52 @@ public enum Calculator {
return CalculatorResult(displayValue: text, copyValue: text, detail: query)
}
private struct CurrencyQuery {
let expression: String
let source: String
let target: String
let amount: Parser
}
private static let cryptoCodes: Set<String> = [
"1inch", "aave", "atom", "avax", "bake", "btcb", "busd", "cake", "celo", "comp",
"dash", "doge", "dydx", "egld", "eurc", "flow", "frax", "gala", "gusd", "hbar",
"kava", "klay", "link", "luna", "lunc", "mana", "mina", "near", "nexo", "ordi",
"paxg", "pepe", "qtum", "rune", "sand", "shib", "theta", "tusd", "usdc", "usdt",
"wbtc", "weth",
]
private static func currencyCode(_ text: String, rates: CurrencyRates?) -> String? {
let code = text.lowercased()
guard (2...12).contains(code.utf8.count),
code.utf8.allSatisfy({ (97...122).contains($0) || (48...57).contains($0) }),
ConversionUnit.parse(text) == nil else { return nil }
let conventional = code.utf8.count == 3 && code.utf8.allSatisfy { (97...122).contains($0) }
return conventional || cryptoCodes.contains(code) || rates?.rates[code] != nil ? code : nil
}
private static func currencyQuery(_ query: String, rates: CurrencyRates?) -> CurrencyQuery? {
guard query.utf8.count <= 512 else { return nil }
let words = query.split(whereSeparator: \.isWhitespace)
guard words.count >= 3,
["to", "in", "as"].contains(words[words.count - 2].lowercased()),
let target = currencyCode(String(words[words.count - 1]), rates: rates) else { return nil }
let sourceText = words.dropLast(2).joined(separator: " ")
let characters = Array(sourceText)
guard characters.count > 1 else { return nil }
for length in stride(from: min(12, characters.count - 1), through: 2, by: -1) {
let suffix = String(characters.suffix(length))
guard let source = currencyCode(suffix, rates: rates) else { continue }
let boundary = characters[characters.count - length - 1]
// Digit-bearing tickers need a space so 21inch cannot silently become 2 × 1INCH.
guard boundary.isWhitespace || (suffix.allSatisfy(\.isLetter) && !boundary.isLetter) else { continue }
let expression = String(characters.dropLast(length)).trimmingCharacters(in: .whitespaces)
guard let amount = parse(expression) else { continue }
return CurrencyQuery(expression: expression, source: source, target: target, amount: amount)
}
return nil
}
private static func failure(_ error: String, detail: String) -> CalculatorResult {
CalculatorResult(displayValue: error, copyValue: nil, detail: detail)
}
+99
View File
@@ -0,0 +1,99 @@
import Foundation
public struct CurrencyRates: Sendable {
/// The provider's daily snapshot date, not a market quote timestamp.
public let date: String
/// Units of each lowercase currency code per EUR.
public let rates: [String: Double]
public init(date: String, rates: [String: Double]) {
self.date = date
self.rates = rates
}
}
public enum CurrencyAPI {
private struct Snapshot: Decodable {
let date: String
let eur: [String: Double]
}
private enum FetchError: LocalizedError {
case invalidResponse
case http(Int)
case invalidSnapshot
case unavailable(String, String)
var errorDescription: String? {
switch self {
case .invalidResponse: "Currency provider returned a non-HTTP response."
case .http(let status): "Currency provider returned HTTP \(status)."
case .invalidSnapshot: "Currency provider returned an invalid daily snapshot."
case .unavailable(let primary, let fallback):
"Currency rates unavailable. CDN: \(primary) Fallback: \(fallback)"
}
}
}
public static func fetch() async throws -> CurrencyRates {
try await fetch { request in
try await URLSession.shared.data(for: request)
}
}
// The provider documents this CDN → Cloudflare fallback:
// https://github.com/fawazahmed0/exchange-api#additional-fallback-url-on-cloudflare
// Inject only the network boundary so tests never make real requests.
static func fetch(
load: @Sendable (URLRequest) async throws -> (Data, URLResponse)
) async throws -> CurrencyRates {
let endpoints = [
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/eur.json",
"https://latest.currency-api.pages.dev/v1/currencies/eur.json",
]
var primaryFailure = ""
for (index, endpoint) in endpoints.enumerated() {
try Task.checkCancellation()
let request = URLRequest(url: URL(string: endpoint)!, cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 12)
do {
let (data, response) = try await load(request)
try Task.checkCancellation()
guard let response = response as? HTTPURLResponse else { throw FetchError.invalidResponse }
guard (200...299).contains(response.statusCode) else { throw FetchError.http(response.statusCode) }
let snapshot: Snapshot
do { snapshot = try JSONDecoder().decode(Snapshot.self, from: data) }
catch { throw FetchError.invalidSnapshot }
guard validDate(snapshot.date), snapshot.eur["eur"] == 1,
snapshot.eur.allSatisfy({ code, rate in
!code.isEmpty && code.utf8.allSatisfy {
(97...122).contains($0) || (48...57).contains($0)
} && rate.isFinite && rate > 0
}) else { throw FetchError.invalidSnapshot }
return CurrencyRates(date: snapshot.date, rates: snapshot.eur)
} catch {
try Task.checkCancellation()
if error is CancellationError || (error as? URLError)?.code == .cancelled { throw error }
if index == 0 { primaryFailure = error.localizedDescription }
else { throw FetchError.unavailable(primaryFailure, error.localizedDescription) }
}
}
throw FetchError.invalidResponse
}
private static func validDate(_ text: String) -> Bool {
let bytes = Array(text.utf8)
guard bytes.count == 10, bytes[4] == 45, bytes[7] == 45,
bytes.enumerated().allSatisfy({ index, byte in
index == 4 || index == 7 || (48...57).contains(byte)
}), let year = Int(text.prefix(4)), year > 0,
let month = Int(text.dropFirst(5).prefix(2)),
let day = Int(text.suffix(2)) else { return false }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let components = DateComponents(year: year, month: month, day: day)
guard let date = calendar.date(from: components) else { return false }
let actual = calendar.dateComponents([.year, .month, .day], from: date)
return actual.year == year && actual.month == month && actual.day == day
}
}
+198
View File
@@ -0,0 +1,198 @@
@testable import ConjureKit
import Foundation
import Testing
@Test
func calculatorCrossConvertsCurrencyExpressionsAndPreservesSnapshotDate() throws {
let snapshot = CurrencyRates(date: "2026-01-02", rates: ["eur": 1, "usd": 2, "dkk": 8])
let result = try #require(Calculator.evaluate("2*50 USD to DKK", currencyRates: snapshot))
#expect(result.displayValue == "400 DKK")
#expect(result.copyValue == "400 DKK")
#expect(result.detail == "2*50 USD → DKK · Daily snapshot 2026-01-02")
}
@Test
func calculatorConvertsFiatAndCryptoWithCaseInsensitiveSeparators() {
let snapshot = CurrencyRates(date: "2026-01-02", rates: [
"eur": 1, "usd": 2, "dkk": 8, "btc": 0.00002, "eth": 0.0005,
"sol": 0.01, "1inch": 4, "arb": 3, "op": 5,
])
let examples = [
("100usd IN dKk", "400 DKK"),
("8 DKK as USD", "2 USD"),
("1 BTC to usd", "100000 USD"),
("10 EUR to ETH", "0.005 ETH"),
("2 sol to EUR", "200 EUR"),
("2 1INCH to usd", "1 USD"),
("2 USD to 1inch", "4 1INCH"),
("2 op to USD", "0.8 USD"),
("sqrt(16) usd to eur", "2 EUR"),
("-2 USD to DKK", "-8 DKK"),
("0 USD to DKK", "0 DKK"),
("1 USD to USD", "1 USD"),
]
for (query, expected) in examples {
#expect(Calculator.isCurrencyQuery(query, currencyRates: snapshot), "\(query)")
#expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == expected, "\(query)")
}
#expect(!Calculator.isCurrencyQuery("2 op to USD"))
}
@Test
func calculatorReportsLoadingUnsupportedCurrenciesAndInvalidAmountsWithoutCopying() throws {
let loading = try #require(Calculator.evaluate("100 usd to dkk"))
#expect(loading.displayValue == "Loading currency rates…")
#expect(loading.copyValue == nil)
#expect(Calculator.isCurrencyQuery("2 1INCH to USD"))
#expect(Calculator.evaluate("2 1INCH to USD")?.copyValue == nil)
let snapshot = CurrencyRates(date: "2026-01-02", rates: ["eur": 1, "usd": 2])
let unsupported = try #require(Calculator.evaluate("1 USD to XYZ", currencyRates: snapshot))
#expect(unsupported.displayValue == "Unsupported currency")
#expect(unsupported.copyValue == nil)
#expect(unsupported.detail.contains("2026-01-02"))
for query in ["1/0 USD to EUR", "sqrt(-1) USD to EUR", "1e999 USD to EUR", "1e308 USD to XYZ"] {
#expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == nil, "\(query)")
}
let broken = CurrencyRates(date: "2026-01-02", rates: ["usd": 0, "eur": 1])
#expect(Calculator.evaluate("1 USD to EUR", currencyRates: broken)?.displayValue == "Invalid currency rates")
let huge = CurrencyRates(date: "2026-01-02", rates: ["usd": 1, "eur": 1, "dkk": 8])
#expect(Calculator.evaluate("1e308 USD to DKK", currencyRates: huge)?.displayValue == "Result is not finite")
}
@Test
func calculatorCurrencyParsingPreservesPhysicalAndApplicationQueries() {
let snapshot = CurrencyRates(date: "2026-01-02", rates: [
"eur": 1, "usd": 2, "cup": 24, "sec": 3, "min": 4,
])
for query in ["Safari", "1Password", "2 Safari to Chrome", "USD to DKK", "2 USD to",
"2 USD", "2 + USD to EUR", "2 USD to EUR app", "2 foo USD to EUR",
"2 1inchh to USD", "21inch to USD", "1 cup to USD", "1 USD to cup",
"1 km to mi", "1 sec to min", "1 GB to seconds", "2 hours in minutes"] {
#expect(!Calculator.isCurrencyQuery(query, currencyRates: snapshot), "\(query)")
}
for query in ["Safari", "1Password", "2 Safari to Chrome", "USD to DKK", "2 USD to",
"2 USD", "2 + USD to EUR", "2 USD to EUR app", "2 foo USD to EUR",
"2 1inchh to USD", "21inch to USD", "1 cup to USD", "1 USD to cup"] {
#expect(Calculator.evaluate(query, currencyRates: snapshot) == nil, "\(query)")
}
#expect(Calculator.evaluate("1 sec to min", currencyRates: snapshot)?.copyValue == "0.0166666666667 min")
#expect(Calculator.evaluate("2 hours in minutes", currencyRates: snapshot)?.copyValue == "120 min")
#expect(Calculator.evaluate("2 + 3", currencyRates: snapshot)?.copyValue == "5")
#expect(!Calculator.isCurrencyQuery(String(repeating: "1+", count: 300) + "1 USD to EUR"))
}
private actor CurrencyFixture {
enum Reply: Sendable {
case response(Int, String)
case failure(URLError.Code)
}
let replies: [Reply]
private(set) var requests: [URLRequest] = []
init(_ replies: [Reply]) { self.replies = replies }
func load(_ request: URLRequest) throws -> (Data, URLResponse) {
requests.append(request)
guard requests.count <= replies.count else { throw URLError(.badServerResponse) }
switch replies[requests.count - 1] {
case .response(let status, let json):
return (Data(json.utf8), HTTPURLResponse(url: request.url!, statusCode: status,
httpVersion: nil, headerFields: nil)!)
case .failure(let code): throw URLError(code)
}
}
}
private let validCurrencyJSON = #"{"date":"2026-01-02","eur":{"eur":1,"usd":2,"btc":0.00002,"1inch":4}}"#
@Test
func currencyFetchLoadsOneEURSnapshotWithBoundedTimeout() async throws {
let fixture = CurrencyFixture([.response(200, validCurrencyJSON)])
let snapshot = try await CurrencyAPI.fetch { try await fixture.load($0) }
#expect(snapshot.date == "2026-01-02")
#expect(snapshot.rates["usd"] == 2)
#expect(snapshot.rates["btc"] == 0.00002)
#expect(snapshot.rates["1inch"] == 4)
let requests = await fixture.requests
#expect(requests.count == 1)
#expect(requests.first?.url?.absoluteString == "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/eur.json")
#expect(requests.first?.timeoutInterval == 12)
#expect(requests.first?.cachePolicy == .reloadIgnoringLocalCacheData)
}
@Test
func currencyFetchFallsBackAfterTransportAndHTTPFailures() async throws {
for failure in [CurrencyFixture.Reply.failure(.timedOut), .response(503, "{}")] {
let fixture = CurrencyFixture([failure, .response(200, validCurrencyJSON)])
let snapshot = try await CurrencyAPI.fetch { try await fixture.load($0) }
#expect(snapshot.date == "2026-01-02")
let requests = await fixture.requests
#expect(requests.count == 2)
#expect(requests.last?.url?.absoluteString == "https://latest.currency-api.pages.dev/v1/currencies/eur.json")
#expect(requests.last?.timeoutInterval == 12)
}
}
@Test(arguments: [
"{}",
"not JSON",
#"{"date":"2026-01-02","eur":{"eur":1,"usd":"2"}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"usd":0}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"usd":-2}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"usd":1e999}}"#,
#"{"date":"2026-01-02","eur":{"eur":2,"usd":2}}"#,
#"{"date":"2026-01-02","eur":{"usd":2}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"USD":2}}"#,
#"{"date":"2026-01-02","eur":{"eur":1,"usd/eur":2}}"#,
#"{"date":"2026-02-30","eur":{"eur":1,"usd":2}}"#,
#"{"date":"2026-1-02","eur":{"eur":1,"usd":2}}"#,
#"{"date":"2026-13-02","eur":{"eur":1,"usd":2}}"#,
#"{"date":"0000-01-02","eur":{"eur":1,"usd":2}}"#,
])
func currencyFetchRejectsInvalidSnapshotsAndUsesFallback(_ json: String) async throws {
let fixture = CurrencyFixture([.response(200, json), .response(200, validCurrencyJSON)])
let snapshot = try await CurrencyAPI.fetch { try await fixture.load($0) }
#expect(snapshot.rates["usd"] == 2)
#expect(await fixture.requests.count == 2)
}
@Test
func currencyFetchReportsBothFailuresWithoutInventingRates() async {
let fixture = CurrencyFixture([.response(503, "{}"), .response(404, "{}")])
do {
_ = try await CurrencyAPI.fetch { try await fixture.load($0) }
Issue.record("Unavailable providers unexpectedly produced currency rates")
} catch {
#expect(error.localizedDescription.contains("HTTP 503"))
#expect(error.localizedDescription.contains("HTTP 404"))
}
#expect(await fixture.requests.count == 2)
}
@Test
func currencyFetchDoesNotRetryCancelledRequests() async {
let fixture = CurrencyFixture([.failure(.cancelled)])
do {
_ = try await CurrencyAPI.fetch { try await fixture.load($0) }
Issue.record("Cancelled request unexpectedly produced currency rates")
} catch {
#expect((error as? URLError)?.code == .cancelled)
}
#expect(await fixture.requests.count == 1)
}
@Test
func currencyFetchRejectsNonHTTPResponsesAndAcceptsValidLeapDayMetadata() async throws {
let fixture = CurrencyFixture([.response(200, #"{"date":"2024-02-29","eur":{"eur":1,"usd":2}}"#)])
let snapshot = try await CurrencyAPI.fetch { request in
if request.url?.host == "cdn.jsdelivr.net" {
return (Data(validCurrencyJSON.utf8),
URLResponse(url: request.url!, mimeType: "application/json",
expectedContentLength: 0, textEncodingName: nil))
}
return try await fixture.load(request)
}
#expect(snapshot.date == "2024-02-29")
#expect(await fixture.requests.count == 1)
}