@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 calculatorConvertsYenAsJPYInEitherDirection() { let snapshot = CurrencyRates(date: "2026-01-02", rates: ["eur": 1, "jpy": 160, "dkk": 8]) for (query, expected) in [ ("1000 yen to dkk", "50 DKK"), ("1000JPY to DKK", "50 DKK"), ("1000YeN in dkk", "50 DKK"), ("50 DKK as yen", "1000 JPY"), ("2*500 yen to JPY", "1000 JPY"), ] { #expect(Calculator.isCurrencyQuery(query), "\(query)") #expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == expected, "\(query)") } } @Test func calculatorConvertsCurrencyNamesWithoutGuessingAmbiguousNames() { let snapshot = CurrencyRates(date: "2026-01-02", rates: [ "eur": 1, "dkk": 8, "usd": 2, "gbp": 0.5, "btc": 0.00002, ]) for (query, expected) in [ ("10 euros to Danish kroner", "80 DKK"), ("80 DANISH KRONER in euro", "10 EUR"), ("2*5 US dollars as British pounds", "2.5 GBP"), ("1 bitcoin to euros", "50000 EUR"), ("10euros to DKK", "80 DKK"), ] { #expect(Calculator.isCurrencyQuery(query)) #expect(Calculator.evaluate(query, currencyRates: snapshot)?.copyValue == expected) } for query in ["10 dollars to EUR", "10 kroner to EUR", "10 pounds to EUR", "10 EUR to dollars"] { #expect(!Calculator.isCurrencyQuery(query, currencyRates: snapshot)) } #expect(Calculator.evaluate("2 pounds to oz", currencyRates: snapshot)?.copyValue == "32 oz") } @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) }