feat: add daily fiat and crypto currency conversion
This commit is contained in:
@@ -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:)).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user