Files
conjure/Tests/ConjureKitTests/CurrencyTests.swift
T

199 lines
9.0 KiB
Swift

@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)
}