feat: add daily fiat and crypto currency conversion
This commit is contained in:
@@ -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