diff --git a/Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift b/Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift index a7cd9ae16..23af6ff3f 100644 --- a/Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift +++ b/Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift @@ -54,7 +54,7 @@ extension Decimal.ParseStrategy { numberFormatType = .percent(format.collection) locale = format.locale } else if let format = formatStyle as? Decimal.FormatStyle.Currency { - numberFormatType = .currency(format.collection) + numberFormatType = .currency(format.collection, currencyCode: format.currencyCode) locale = format.locale } else { // For some reason we've managed to accept a format style of a type that we don't own, which shouldn't happen. Fallback to the default decimal style and try anyways. diff --git a/Sources/FoundationInternationalization/Formatting/Number/FloatingPointFormatStyle.swift b/Sources/FoundationInternationalization/Formatting/Number/FloatingPointFormatStyle.swift index ce6b3720d..047efe4e6 100644 --- a/Sources/FoundationInternationalization/Formatting/Number/FloatingPointFormatStyle.swift +++ b/Sources/FoundationInternationalization/Formatting/Number/FloatingPointFormatStyle.swift @@ -418,7 +418,7 @@ extension FloatingPointFormatStyle { extension FloatingPointFormatStyle : CustomConsumingRegexComponent { public typealias RegexOutput = Value public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range) throws -> (upperBound: String.Index, output: Value)? { - FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) + try FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) } } @@ -426,7 +426,7 @@ extension FloatingPointFormatStyle : CustomConsumingRegexComponent { extension FloatingPointFormatStyle.Percent : CustomConsumingRegexComponent { public typealias RegexOutput = Value public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range) throws -> (upperBound: String.Index, output: Value)? { - FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) + try FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) } } @@ -434,7 +434,7 @@ extension FloatingPointFormatStyle.Percent : CustomConsumingRegexComponent { extension FloatingPointFormatStyle.Currency : CustomConsumingRegexComponent { public typealias RegexOutput = Value public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range) throws -> (upperBound: String.Index, output: Value)? { - FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) + try FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) } } diff --git a/Sources/FoundationInternationalization/Formatting/Number/FloatingPointParseStrategy.swift b/Sources/FoundationInternationalization/Formatting/Number/FloatingPointParseStrategy.swift index 5c9350e30..92f241149 100644 --- a/Sources/FoundationInternationalization/Formatting/Number/FloatingPointParseStrategy.swift +++ b/Sources/FoundationInternationalization/Formatting/Number/FloatingPointParseStrategy.swift @@ -18,8 +18,6 @@ import FoundationEssentials public struct FloatingPointParseStrategy : Codable, Hashable where Format : FormatStyle, Format.FormatInput : BinaryFloatingPoint { public var formatStyle: Format public var lenient: Bool - var numberFormatType: ICULegacyNumberFormatter.NumberFormatType - var locale: Locale } @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) @@ -29,27 +27,42 @@ extension FloatingPointParseStrategy : Sendable where Format : Sendable {} extension FloatingPointParseStrategy: ParseStrategy { @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) public func parse(_ value: String) throws -> Format.FormatInput { - guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else { - throw CocoaError(CocoaError.formatting, userInfo: [ - NSDebugDescriptionErrorKey: "Cannot parse \(value), unable to create formatter" ]) - } - if let v = parser.parseAsDouble(value._trimmingWhitespace()) { - return Format.FormatInput(v) - } else { + let trimmedString = value._trimmingWhitespace() + guard let result = try parse(trimmedString, startingAt: trimmedString.startIndex, in: trimmedString.startIndex..) -> (String.Index, Format.FormatInput)? { + internal func parse(_ value: String, startingAt index: String.Index, in range: Range) throws -> (String.Index, Format.FormatInput)? { guard index < range.upperBound else { return nil } + let numberFormatType: ICULegacyNumberFormatter.NumberFormatType + let locale: Locale + + if let format = formatStyle as? FloatingPointFormatStyle { + numberFormatType = .number(format.collection) + locale = format.locale + } else if let format = formatStyle as? FloatingPointFormatStyle.Percent { + numberFormatType = .percent(format.collection) + locale = format.locale + } else if let format = formatStyle as? FloatingPointFormatStyle.Currency { + numberFormatType = .currency(format.collection, currencyCode: format.currencyCode) + locale = format.locale + } else { + // For some reason we've managed to accept a format style of a type that we don't own, which shouldn't happen. Fallback to the default decimal style and try anyways. + numberFormatType = .number(.init()) + locale = .autoupdatingCurrent + } + guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else { - return nil + throw CocoaError(CocoaError.formatting, userInfo: [ + NSDebugDescriptionErrorKey: "Cannot parse \(value), unable to create formatter" ]) } let substr = value[index..(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle { self.formatStyle = format self.lenient = lenient - self.locale = format.locale - self.numberFormatType = .number(format.collection) } } @@ -78,8 +89,6 @@ public extension FloatingPointParseStrategy { init(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle.Currency { self.formatStyle = format self.lenient = lenient - self.locale = format.locale - self.numberFormatType = .currency(format.collection) } } @@ -88,7 +97,5 @@ public extension FloatingPointParseStrategy { init(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle.Percent { self.formatStyle = format self.lenient = lenient - self.locale = format.locale - self.numberFormatType = .percent(format.collection) } } diff --git a/Sources/FoundationInternationalization/Formatting/Number/ICULegacyNumberFormatter.swift b/Sources/FoundationInternationalization/Formatting/Number/ICULegacyNumberFormatter.swift index d2f321e79..a40004c37 100644 --- a/Sources/FoundationInternationalization/Formatting/Number/ICULegacyNumberFormatter.swift +++ b/Sources/FoundationInternationalization/Formatting/Number/ICULegacyNumberFormatter.swift @@ -123,7 +123,7 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable { enum NumberFormatType : Hashable, Codable { case number(NumberFormatStyleConfiguration.Collection) case percent(NumberFormatStyleConfiguration.Collection) - case currency(CurrencyFormatStyleConfiguration.Collection) + case currency(CurrencyFormatStyleConfiguration.Collection, currencyCode: String) case descriptive(DescriptiveNumberFormatConfiguration.Collection) } @@ -143,7 +143,7 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable { } case .percent(_): icuType = .percent - case .currency(let config): + case .currency(let config, _): icuType = config.icuNumberFormatStyle case .descriptive(let config): icuType = config.icuNumberFormatStyle @@ -178,12 +178,13 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable { } } - case .currency(let config): + case .currency(let config, let currencyCode): setMultiplier(config.scale, formatter: formatter) setPrecision(config.precision, formatter: formatter) setGrouping(config.group, formatter: formatter) setDecimalSeparator(config.decimalSeparatorStrategy, formatter: formatter) setRoundingIncrement(config.roundingIncrement, formatter: formatter) + try setTextAttribute(.currencyCode, formatter: formatter, value: currencyCode) // Currency specific attributes if let sign = config.signDisplayStrategy { diff --git a/Sources/FoundationInternationalization/Formatting/Number/IntegerFormatStyle.swift b/Sources/FoundationInternationalization/Formatting/Number/IntegerFormatStyle.swift index 421a10902..23f9b7e0a 100644 --- a/Sources/FoundationInternationalization/Formatting/Number/IntegerFormatStyle.swift +++ b/Sources/FoundationInternationalization/Formatting/Number/IntegerFormatStyle.swift @@ -563,7 +563,7 @@ extension IntegerFormatStyle { extension IntegerFormatStyle : CustomConsumingRegexComponent { public typealias RegexOutput = Value public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range) throws -> (upperBound: String.Index, output: Value)? { - IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) + try IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) } } @@ -571,7 +571,7 @@ extension IntegerFormatStyle : CustomConsumingRegexComponent { extension IntegerFormatStyle.Percent : CustomConsumingRegexComponent { public typealias RegexOutput = Value public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range) throws -> (upperBound: String.Index, output: Value)? { - IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) + try IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) } } @@ -579,7 +579,7 @@ extension IntegerFormatStyle.Percent : CustomConsumingRegexComponent { extension IntegerFormatStyle.Currency : CustomConsumingRegexComponent { public typealias RegexOutput = Value public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range) throws -> (upperBound: String.Index, output: Value)? { - IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) + try IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds) } } diff --git a/Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift b/Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift index a6ad0b735..078683b7c 100644 --- a/Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift +++ b/Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift @@ -18,8 +18,6 @@ import FoundationEssentials public struct IntegerParseStrategy : Codable, Hashable where Format : FormatStyle, Format.FormatInput : BinaryInteger { public var formatStyle: Format public var lenient: Bool - var numberFormatType: ICULegacyNumberFormatter.NumberFormatType - var locale: Locale } @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) @@ -28,51 +26,63 @@ extension IntegerParseStrategy : Sendable where Format : Sendable {} @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) extension IntegerParseStrategy: ParseStrategy { public func parse(_ value: String) throws -> Format.FormatInput { - guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else { - throw CocoaError(CocoaError.formatting, userInfo: [ - NSDebugDescriptionErrorKey: "Cannot parse \(value). Could not create parser." ]) - } let trimmedString = value._trimmingWhitespace() - if let v = parser.parseAsInt(trimmedString) { - guard let exact = Format.FormatInput(exactly: v) else { - throw CocoaError(CocoaError.formatting, userInfo: [ - NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ]) - } - return exact - } else if let v = parser.parseAsDouble(trimmedString) { - guard v.magnitude < Double(sign: .plus, exponent: Double.significandBitCount + 1, significand: 1) else { - throw CocoaError(CocoaError.formatting, userInfo: [ - NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the lossless floating-point range" ]) - } - guard let exact = Format.FormatInput(exactly: v) else { - throw CocoaError(CocoaError.formatting, userInfo: [ - NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ]) - } - return exact - } else { + guard let result = try parse(trimmedString, startingAt: trimmedString.startIndex, in: trimmedString.startIndex..) -> (String.Index, Format.FormatInput)? { + internal func parse(_ value: String, startingAt index: String.Index, in range: Range) throws -> (String.Index, Format.FormatInput)? { guard index < range.upperBound else { return nil } + let numberFormatType: ICULegacyNumberFormatter.NumberFormatType + let locale: Locale + + if let format = formatStyle as? IntegerFormatStyle { + numberFormatType = .number(format.collection) + locale = format.locale + } else if let format = formatStyle as? IntegerFormatStyle.Percent { + numberFormatType = .percent(format.collection) + locale = format.locale + } else if let format = formatStyle as? IntegerFormatStyle.Currency { + numberFormatType = .currency(format.collection, currencyCode: format.currencyCode) + locale = format.locale + } else { + // For some reason we've managed to accept a format style of a type that we don't own, which shouldn't happen. Fallback to the default decimal style and try anyways. + numberFormatType = .number(.init()) + locale = .autoupdatingCurrent + } + guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else { return nil } let substr = value[index..(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle { self.formatStyle = format self.lenient = lenient - self.locale = format.locale - self.numberFormatType = .number(format.collection) } } @@ -92,8 +100,6 @@ public extension IntegerParseStrategy { init(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle.Percent { self.formatStyle = format self.lenient = lenient - self.locale = format.locale - self.numberFormatType = .percent(format.collection) } } @@ -102,7 +108,5 @@ public extension IntegerParseStrategy { init(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle.Currency { self.formatStyle = format self.lenient = lenient - self.locale = format.locale - self.numberFormatType = .currency(format.collection) } } diff --git a/Sources/FoundationInternationalization/ICU/ICU+Enums.swift b/Sources/FoundationInternationalization/ICU/ICU+Enums.swift index 65b7e061c..1f1a99cb6 100644 --- a/Sources/FoundationInternationalization/ICU/ICU+Enums.swift +++ b/Sources/FoundationInternationalization/ICU/ICU+Enums.swift @@ -109,6 +109,7 @@ extension UNumberFormatAttribute { extension UNumberFormatTextAttribute { static let defaultRuleSet = UNUM_DEFAULT_RULESET + static let currencyCode = UNUM_CURRENCY_CODE } extension UDateRelativeDateTimeFormatterStyle { diff --git a/Sources/TestSupport/TestSupport.swift b/Sources/TestSupport/TestSupport.swift index 04c5a2db3..780d0dc06 100644 --- a/Sources/TestSupport/TestSupport.swift +++ b/Sources/TestSupport/TestSupport.swift @@ -49,6 +49,8 @@ public typealias FloatingPointFormatStyle = Foundation.FloatingPointFormatStyle public typealias NumberFormatStyleConfiguration = Foundation.NumberFormatStyleConfiguration @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) public typealias CurrencyFormatStyleConfiguration = Foundation.CurrencyFormatStyleConfiguration +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +public typealias IntegerParseStrategy = Foundation.IntegerParseStrategy @available(FoundationPreview 0.4, *) public typealias DiscreteFormatStyle = Foundation.DiscreteFormatStyle @@ -147,6 +149,8 @@ public typealias FloatingPointFormatStyle = FoundationInternationalization.Float public typealias NumberFormatStyleConfiguration = FoundationInternationalization.NumberFormatStyleConfiguration @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) public typealias CurrencyFormatStyleConfiguration = FoundationInternationalization.CurrencyFormatStyleConfiguration +@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) +public typealias IntegerParseStrategy = FoundationInternationalization.IntegerParseStrategy @available(FoundationPreview 0.4, *) public typealias DiscreteFormatStyle = FoundationEssentials.DiscreteFormatStyle diff --git a/Tests/FoundationInternationalizationTests/Formatting/NumberFormatStyleTests.swift b/Tests/FoundationInternationalizationTests/Formatting/NumberFormatStyleTests.swift index a45f6ccef..c9764ad37 100644 --- a/Tests/FoundationInternationalizationTests/Formatting/NumberFormatStyleTests.swift +++ b/Tests/FoundationInternationalizationTests/Formatting/NumberFormatStyleTests.swift @@ -595,6 +595,37 @@ final class NumberFormatStyleTests: XCTestCase { XCTAssertEqual((-922337203685477 as Decimal).formatted(baseStyle.rounded(rule: .awayFromZero, increment: 100)), "-$1000T") } + func testCurrency_Codable() throws { + let gbpInUS = Decimal.FormatStyle.Currency(code: "GBP", locale: enUSLocale) + let encoded = try JSONEncoder().encode(gbpInUS) + let json_gbp = String(data: encoded, encoding: String.Encoding.utf8)! + let previouslyEncoded = """ + { + "collection": + { + "presentation": + { + "option": 1 + } + }, + "currencyCode": "GBP", + "locale": + { + "current": 0, + "identifier": "en_US" + } + } +""".data(using: .utf8) + + guard let previouslyEncoded else { + XCTFail() + return + } + + let decoded = try JSONDecoder().decode(Decimal.FormatStyle.Currency, from: previouslyEncoded) + XCTAssertEqual(decoded, gbpInUS) + } + func testCurrency_scientific() throws { let baseStyle = Decimal.FormatStyle.Currency(code: "USD", locale: Locale(identifier: "en_US")).notation(.scientific) @@ -1905,23 +1936,33 @@ final class FormatStylePatternMatchingTests : XCTestCase { _verifyMatching("€52,249", formatStyle: floatStyle, expectedUpperBound: nil, expectedValue: nil) _verifyMatching("€52,249", formatStyle: decimalStyle, expectedUpperBound: nil, expectedValue: nil) - // Different locale let frenchStyle: IntegerFormatStyle.Currency = .init(code: "EUR", locale: frFR) - - let frenchPrice = "57 379 €" - _verifyMatching(frenchPrice, formatStyle: frenchStyle, expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379) - _verifyMatching(frenchPrice, formatStyle: floatStyle.locale(frFR), expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379) - _verifyMatching(frenchPrice, formatStyle: decimalStyle.locale(frFR), expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379) - - _verifyMatching("\(frenchPrice) semble beaucoup", formatStyle: frenchStyle, expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379) - _verifyMatching("\(frenchPrice) semble beaucoup", formatStyle: floatStyle.locale(frFR), expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379) - _verifyMatching("\(frenchPrice) semble beaucoup", formatStyle: decimalStyle.locale(frFR), expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379) - + let frenchPrice = frenchStyle.format(57379) + XCTAssertEqual(frenchPrice, "57 379,00 €") + _verifyMatching("57 379,00 €", formatStyle: frenchStyle, expectedUpperBound: "57 379,00 €".endIndex, expectedValue: 57379) + _verifyMatching("57 379 €", formatStyle: frenchStyle, expectedUpperBound: "57 379 €".endIndex, expectedValue: 57379) + _verifyMatching("57 379,00 € semble beaucoup", formatStyle: frenchStyle, expectedUpperBound: "57 379,00 €".endIndex, expectedValue: 57379) + + // Does not match when matching with USD style + _verifyMatching("57 379,00 €", formatStyle: floatStyle.locale(frFR), expectedUpperBound: nil, expectedValue: nil) + _verifyMatching("57 379,00 €", formatStyle: decimalStyle.locale(frFR), expectedUpperBound: nil, expectedValue: nil) + + // Mix currency and locale + _verifyMatching("57 379,00 $US", formatStyle: floatStyle.locale(frFR), expectedUpperBound: "57 379,00 $US".endIndex, expectedValue: 57379) + _verifyMatching("57 379,00 $US", formatStyle: decimalStyle.locale(frFR), expectedUpperBound: "57 379,00 $US".endIndex, expectedValue: 57379) + _verifyMatching("57 379,00 $US semble beaucoup", formatStyle: floatStyle.locale(frFR), expectedUpperBound: "57 379,00 $US".endIndex, expectedValue: 57379) + _verifyMatching("57 379,00 $US semble beaucoup", formatStyle: decimalStyle.locale(frFR), expectedUpperBound: "57 379,00 $US".endIndex, expectedValue: 57379) + + // Range tests let newFrenchStr = " coûte \(frenchPrice)" let frenchMatchRange = newFrenchStr.firstIndex(of: "5")! ..< newFrenchStr.endIndex _verifyMatching(newFrenchStr, formatStyle: frenchStyle, range: frenchMatchRange, expectedUpperBound: newFrenchStr.endIndex, expectedValue: 57379) - _verifyMatching(newFrenchStr, formatStyle: floatStyle.locale(frFR), range: frenchMatchRange, expectedUpperBound: newFrenchStr.endIndex, expectedValue: 57379) - _verifyMatching(newFrenchStr, formatStyle: decimalStyle.locale(frFR), range: frenchMatchRange, expectedUpperBound: newFrenchStr.endIndex, expectedValue: 57379) + + // Mix currency and locale range tests + let newFrenchUSDStr = " coûte 57 379,00 $US" + let usdPriceRange = newFrenchUSDStr.firstIndex(of: "5")! ..< newFrenchUSDStr.endIndex + _verifyMatching(newFrenchUSDStr, formatStyle: floatStyle.locale(frFR), range: usdPriceRange, expectedUpperBound: newFrenchUSDStr.endIndex, expectedValue: 57379) + _verifyMatching(newFrenchUSDStr, formatStyle: decimalStyle.locale(frFR), range: usdPriceRange, expectedUpperBound: newFrenchUSDStr.endIndex, expectedValue: 57379) // Sign tests let signTests = [ diff --git a/Tests/FoundationInternationalizationTests/Formatting/NumberParseStrategyTests.swift b/Tests/FoundationInternationalizationTests/Formatting/NumberParseStrategyTests.swift index becd50269..02b1fab40 100644 --- a/Tests/FoundationInternationalizationTests/Formatting/NumberParseStrategyTests.swift +++ b/Tests/FoundationInternationalizationTests/Formatting/NumberParseStrategyTests.swift @@ -157,6 +157,105 @@ final class NumberParseStrategyTests : XCTestCase { _verifyRoundtripCurrency(negativeData, currencyStyle.decimalSeparator(strategy: .always), "currency style, decimal display: always") } + func testParseCurrencyWithDifferentCodes() throws { + let enUS = Locale(identifier: "en_US") + // Decimal + let style = Decimal.FormatStyle.Currency(code: "GBP", locale: enUS).presentation(.isoCode) + XCTAssertEqual(style.format(3.14), "GBP 3.14") + + let parsed = try style.parseStrategy.parse("GBP 3.14") + XCTAssertEqual(parsed, 3.14) + + // Floating point + let floatingPointStyle: FloatingPointFormatStyle.Currency = .init(code: "GBP", locale: enUS).presentation(.isoCode) + XCTAssertEqual(floatingPointStyle.format(3.14), "GBP 3.14") + + let parsedFloatingPoint = try floatingPointStyle.parseStrategy.parse("GBP 3.14") + XCTAssertEqual(parsedFloatingPoint, 3.14) + + // Integer + let integerStyle: IntegerFormatStyle.Currency = .init(code: "GBP", locale: enUS).presentation(.isoCode) + XCTAssertEqual(integerStyle.format(32), "GBP 32.00") + + let parsedInt = try integerStyle.parseStrategy.parse("GBP 32.00") + XCTAssertEqual(parsedInt, 32) + } + + func test_roundtripForeignCurrency() throws { + let testData: [Int] = [ + 87650000, 8765000, 876500, 87650, 8765, 876, 87, 8, 0 + ] + let negativeData: [Int] = [ + -87650000, -8765000, -876500, -87650, -8765, -876, -87, -8 + ] + + func _verifyRoundtripCurrency(_ testData: [Int], _ style: IntegerFormatStyle.Currency, _ testName: String = "", file: StaticString = #filePath, line: UInt = #line) throws { + for value in testData { + let str = style.format(value) + let parsed = try Int(str, strategy: style.parseStrategy) + XCTAssertEqual(value, parsed, "\(testName): formatted string: \(str) parsed: \(parsed)", file: file, line: line) + + let nonLenientParsed = try Int(str, format: style, lenient: false) + XCTAssertEqual(value, nonLenientParsed, file: file, line: line) + } + } + + let currencyStyle: IntegerFormatStyle.Currency = .init(code: "EUR", locale: Locale(identifier: "en_US")) + try _verifyRoundtripCurrency(testData, currencyStyle, "currency style") + try _verifyRoundtripCurrency(testData, currencyStyle.sign(strategy: .always()), "currency style, sign: always") + try _verifyRoundtripCurrency(testData, currencyStyle.grouping(.never), "currency style, grouping: never") + try _verifyRoundtripCurrency(testData, currencyStyle.presentation(.isoCode), "currency style, presentation: iso code") + try _verifyRoundtripCurrency(testData, currencyStyle.presentation(.fullName), "currency style, presentation: iso code") + try _verifyRoundtripCurrency(testData, currencyStyle.presentation(.narrow), "currency style, presentation: iso code") + try _verifyRoundtripCurrency(testData, currencyStyle.decimalSeparator(strategy: .always), "currency style, decimal display: always") + + try _verifyRoundtripCurrency(negativeData, currencyStyle, "currency style") + try _verifyRoundtripCurrency(negativeData, currencyStyle.sign(strategy: .accountingAlways()), "currency style, sign: always") + try _verifyRoundtripCurrency(negativeData, currencyStyle.grouping(.never), "currency style, grouping: never") + try _verifyRoundtripCurrency(negativeData, currencyStyle.presentation(.isoCode), "currency style, presentation: iso code") + try _verifyRoundtripCurrency(negativeData, currencyStyle.presentation(.fullName), "currency style, presentation: iso code") + try _verifyRoundtripCurrency(negativeData, currencyStyle.presentation(.narrow), "currency style, presentation: iso code") + try _verifyRoundtripCurrency(negativeData, currencyStyle.decimalSeparator(strategy: .always), "currency style, decimal display: always") + } + + func test_parseStategyCodable_sameCurrency() throws { + // same currency code + let fs: IntegerFormatStyle.Currency = .init(code: "USD", locale: Locale(identifier:"en_US")) + let p = IntegerParseStrategy(format: fs) + // Valid JSON representation for `p` + let existingSerializedParseStrategy = """ + {"formatStyle":{"locale":{"current":0,"identifier":"en_US"},"collection":{"presentation":{"option":1}},"currencyCode":"USD"},"numberFormatType":{"currency":{"_0":{"presentation":{"option":1}}}},"lenient":true,"locale":{"identifier":"en_US","current":0}} + """ + + guard let existingData = existingSerializedParseStrategy.data(using: .utf8) else { + XCTFail("Unable to get data from JSON string") + return + } + + let decoded: IntegerParseStrategy.Currency> = try JSONDecoder().decode(IntegerParseStrategy.Currency>.self, from: existingData) + XCTAssertEqual(decoded, p) + XCTAssertEqual(decoded.formatStyle, fs) + XCTAssertEqual(decoded.formatStyle.currencyCode, "USD") + } + + func test_parseStategyCodable_differentCurrency() throws { + let fs: IntegerFormatStyle.Currency = .init(code: "GBP", locale: Locale(identifier:"en_US")) + let p = IntegerParseStrategy(format: fs) + // Valid JSON representation for `p` + let existingSerializedParseStrategy = """ + {"formatStyle":{"collection":{"presentation":{"option":1}},"locale":{"current":0,"identifier":"en_US"},"currencyCode":"GBP"},"lenient":true,"locale":{"current":0,"identifier":"en_US"},"numberFormatType":{"currency":{"_0":{"presentation":{"option":1}}}}} + """ + + guard let existingData = existingSerializedParseStrategy.data(using: .utf8) else { + XCTFail("Unable to get data from JSON string") + return + } + let decoded: IntegerParseStrategy.Currency> = try JSONDecoder().decode(IntegerParseStrategy.Currency>.self, from: existingData) + XCTAssertEqual(decoded, p) + XCTAssertEqual(decoded.formatStyle, fs) + XCTAssertEqual(decoded.formatStyle.currencyCode, "GBP") + } + let testNegativePositiveDecimalData: [Decimal] = [ Decimal(string:"87650")!, Decimal(string:"8765")!, Decimal(string:"876.5")!, Decimal(string:"87.65")!, Decimal(string:"8.765")!, Decimal(string:"0.8765")!, Decimal(string:"0.08765")!, Decimal(string:"0.008765")!, Decimal(string:"0")!, Decimal(string:"-0.008765")!, Decimal(string:"-876.5")!, Decimal(string:"-87650")! ] @@ -188,7 +287,7 @@ final class NumberParseStrategyTests : XCTestCase { XCTAssertEqual(try! strategy.parse("-1,234.56 US dollars"), Decimal(string: "-1234.56")!) XCTAssertEqual(try! strategy.parse("-USD\u{00A0}1,234.56"), Decimal(string: "-1234.56")!) } - + func testNumericBoundsParsing() throws { let locale = Locale(identifier: "en_US") do { @@ -307,3 +406,4 @@ final class NumberExtensionParseStrategyTests: XCTestCase { XCTAssertEqual(try Decimal("-$3000.0000014", format: .currency(code: "USD")), Decimal(string: "-3000.0000014")!) } } +