0

I have the following JSON payload that I need to convert to numbers and subsequently format for display.

{
    "kilometers_per_second": "14.4578929636",
    "kilometers_per_hour": "52048.4146691173",
    "miles_per_hour": "32340.8607703746"
}

Using Codable, I created the following structure:

struct RelativeVelocity: Codable, Equatable {
    let kilometersPerSecond: String?
    let kilometersPerHour: String?
    let milesPerHour: String?

    enum CodingKeys: String, CodingKey {
        case kilometersPerSecond = "kilometers_per_second"
        case kilometersPerHour = "kilometers_per_hour"
        case milesPerHour = "miles_per_hour"
    }
}

The properties are String instances because that's what the API returns, and I am learning to use view models for the first time, so I would like to use a view model to convert the String instances into numbers prior to returning formatted String instances.

My view model has the following structure:

struct RelativeVelocityViewModel {
    private let relativeVelocity: RelativeVelocity

    init(relativeVelocity: RelativeVelocity) {
        self.relativeVelocity = relativeVelocity
    }
}

extension RelativeVelocityViewModel {
    var formattedKilometersPerHour: String? {
        guard
            let stringValue = relativeVelocity.kilometersPerHour,
            let decimalValue = Decimal(string: stringValue),
            let formatted = NumberFormatter.relativeVelocityFormatter.string(from: decimalValue as NSNumber)
        else { return nil }
        return formatted
    }

    var formattedKilometersPerSecond: String? {
        guard
            let stringValue = relativeVelocity.kilometersPerSecond,
            let decimalValue = Decimal(string: stringValue),
            let formatted = NumberFormatter.relativeVelocityFormatter.string(from: decimalValue as NSNumber)
        else { return nil }
        return formatted
    }

    var formattedMilesPerHour: String? {
        guard
            let stringValue = relativeVelocity.kilometersPerSecond,
            let decimalValue = Decimal(string: stringValue),
            let formatted = NumberFormatter.relativeVelocityFormatter.string(from: decimalValue as NSNumber)
        else { return nil }
        return formatted
    }
}

As you can see, it converts the String instances into Decimal instances, and the Decimal instances are then formatted by the following NumberFormatter:

extension NumberFormatter {
    static let relativeVelocityFormatter: NumberFormatter = {
        let formatter = NumberFormatter()
        formatter.maximumFractionDigits = .max
        formatter.numberStyle = .decimal
        formatter.usesGroupingSeparator = true
        return formatter
    }()
}

My XCTestCase subclass for testing my view models is:

class Tests_RelativeVelocityViewModel: XCTestCase {

    let kilometersPerSecond = "14.4578929636"
    let kilometersPerHour = "52048.4146691173"
    let milesPerHour = "32340.8607703746"
    var populatedViewModel: RelativeVelocityViewModel!
    var emptyViewModel: RelativeVelocityViewModel!

    override func setUpWithError() throws {
        try super.setUpWithError()
        let populatedRelativeVelocity = RelativeVelocity(
            kilometersPerSecond: kilometersPerSecond,
            kilometersPerHour: kilometersPerHour,
            milesPerHour: milesPerHour
        )
        populatedViewModel = RelativeVelocityViewModel(relativeVelocity: populatedRelativeVelocity)
        let emptyRelativeVelocity = RelativeVelocity(
            kilometersPerSecond: nil,
            kilometersPerHour: nil,
            milesPerHour: nil
        )
        emptyViewModel = RelativeVelocityViewModel(relativeVelocity: emptyRelativeVelocity)
    }

    override func tearDownWithError() throws {
        emptyViewModel = nil
        populatedViewModel = nil
        try super.tearDownWithError()
    }

    func test_RelativeVelocityViewModel_ReturnsNilFormattedKilometersPerHour_WhenValueIsMissing() {
        XCTAssertNil(emptyViewModel.formattedKilometersPerHour)
    }

    func test_RelativeVelocityViewModel_ReturnsFormattedKilometersPerHour_WhenValueIsPresent() {
        let expected = "52,048.4146691173"
        XCTAssertEqual(populatedViewModel.formattedKilometersPerHour, expected)
    }

    func test_RelativeVelocityViewModel_ReturnsNilFormattedKilometersPerSecond_WhenValueIsMissing() {
        XCTAssertNil(emptyViewModel.formattedKilometersPerSecond)
    }

    func test_RelativeVelocityViewModel_ReturnsNilFormattedMilesPerHour_WhenValueIsMissing() {
        XCTAssertNil(emptyViewModel.formattedMilesPerHour)
    }

}

The following test...

func test_RelativeVelocityViewModel_ReturnsFormattedKilometersPerHour_WhenValueIsPresent() {
    let expected = "52,048.4146691173"
    XCTAssertEqual(populatedViewModel.formattedKilometersPerHour, expected)
}

...produces the following failure:

XCTAssertEqual failed: ("Optional("52,048.414669")") is not equal to ("Optional("52,048.4146691173")")

I know that I can use XCTAssertEqual(_:_:accuracy:_:file:line:), but I want to retain all of the decimal values.

What am I doing incorrectly that is causing the formatted result to be rounded by losing the value's precision?

Nick Kohrn
  • 5,779
  • 3
  • 29
  • 49
  • Not related to your question but do you really need to declare all your properties as optional? Btw no need to use custom keys. You can set the decoder keyDecodingStrategy to convertFromSnakeCase `decoder.keyDecodingStrategy = .convertFromSnakeCase` – Leo Dabus Aug 14 '20 at 14:55
  • 1
    The issue there is that you are setting the maximum fraction digits to Int.max. This is way too much for the number formatter that it is ignoring it. – Leo Dabus Aug 14 '20 at 15:25
  • @LeoDabus I am using optional properties because I am using the NASA APIs, and I don't control the server, so I can't be certain that I am getting back data in the expected formats. I don't want to use default values since I don't know what values would be appropriate for man of these properties. I will set a more suitable value for `maximumFractionDigits`. – Nick Kohrn Aug 14 '20 at 16:31
  • 1
    Regarding the decoding part of your decimals https://stackoverflow.com/a/62997953/2303865 `extension KeyedDecodingContainer {` `func decode(_ type: Decimal.Type, forKey key: K) throws -> Decimal {` `let string = try decode(String.self, forKey: key)` `guard let decimal = Decimal(string: string) else {` `let context = DecodingError.Context(codingPath: [key], debugDescription: "The string value for the key \(key) couldn't be converted into a Decimal value: \(string)")` `throw DecodingError.dataCorrupted(context)` `}` `return decimal` `}` `}` – Leo Dabus Aug 14 '20 at 16:33
  • 1
    Regarding a suitable value for maximumFractionDigits is probably 10. – Leo Dabus Aug 14 '20 at 16:38
  • 1
    Btw no need to cast your decimal to NSNumber to use with NumberFormatter. You can use Formatter's `string(for: Any)` method instead of `string(from: NSNumber)` – Leo Dabus Aug 14 '20 at 17:01

1 Answers1

0

Try this:

class MyProjectTests: XCTestCase {

    func testExample() throws {
        let stringValue = "52048.12345678911111"
        let decimalValue = Decimal(string: stringValue)!
        let formatted = NumberFormatter.relativeVelocityFormatter(maxFractionDigits: decimalValue.significantFractionalDecimalDigits).string(from: decimalValue as NSNumber)
        XCTAssert(formatted == stringValue)
    }
}

extension NumberFormatter {
    static func relativeVelocityFormatter(maxFractionDigits: Int) -> NumberFormatter {
        let formatter = NumberFormatter()
        formatter.maximumFractionDigits = maxFractionDigits
        formatter.numberStyle = .none
        formatter.usesGroupingSeparator = true
        return formatter
    }
}

extension Decimal {
    var significantFractionalDecimalDigits: Int {
        return max(-exponent, 0)
    }
}

Anyway, there is always a limit:

enter image description here

33 decimal digits.

Sergio
  • 1,610
  • 14
  • 28
  • There is no specific precision for decimal digits, there is a precision for mantissa (all digits). Also, the result may change depending on iOS version. In the past `NumberFormatter` was converting all numbers to `Double` first. That's why `NSDecimalNumber` has its own formatting method. – Sulthan Aug 14 '20 at 15:41