From cc9faf5aea1d8b85a2e68e555e3bcbd0a9fa28ac Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 4 Apr 2024 16:55:19 +0200 Subject: [PATCH] Improve Number Formatting (#1266) * Improves number formatting (IOS-246) * Implement formatting > 1T (IOS-246) * Fix typo (IOS-246) * Update MastodonTests/MetricFormatterTests.swift Co-authored-by: Nathan Mattes * Improve decimal formatting and add tests (IOS-246) --------- Co-authored-by: Nathan Mattes --- Mastodon.xcodeproj/project.pbxproj | 4 + Mastodon/Mastodon.xctestplan | 15 ++- .../Helper/MastodonMetricFormatter.swift | 56 ++++++-- MastodonTests/MetricFormatterTests.swift | 126 ++++++++++++++++++ 4 files changed, 188 insertions(+), 13 deletions(-) create mode 100644 MastodonTests/MetricFormatterTests.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index deafa1897..d4139d7a9 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -50,6 +50,7 @@ 2A86A14629892944007F1062 /* MultiFollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */; }; 2A86A14929892B3A007F1062 /* MultiFollowersCountWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */; }; 2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */; }; + 2A8DCC612BBEA6DE00B2A4EC /* MetricFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A8DCC602BBEA6DE00B2A4EC /* MetricFormatterTests.swift */; }; 2A90A157296EEE500026C155 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A90A156296EEE500026C155 /* MastodonSDKDynamic */; }; 2A9D0664298C048800BF38CB /* LatestFollowersWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */; }; 2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */; }; @@ -652,6 +653,7 @@ 2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountIntentHandler.swift; sourceTree = ""; }; 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidget.swift; sourceTree = ""; }; 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidgetView.swift; sourceTree = ""; }; + 2A8DCC602BBEA6DE00B2A4EC /* MetricFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricFormatterTests.swift; sourceTree = ""; }; 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidget.swift; sourceTree = ""; }; 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidgetView.swift; sourceTree = ""; }; 2AAAA34D2B04DE21004C6672 /* VisionKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VisionKit.framework; path = System/Library/Frameworks/VisionKit.framework; sourceTree = SDKROOT; }; @@ -2115,6 +2117,7 @@ isa = PBXGroup; children = ( DB427DEC25BAA00100D1B89D /* MastodonTests.swift */, + 2A8DCC602BBEA6DE00B2A4EC /* MetricFormatterTests.swift */, DB427DEE25BAA00100D1B89D /* Info.plist */, ); path = MastodonTests; @@ -3848,6 +3851,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2A8DCC612BBEA6DE00B2A4EC /* MetricFormatterTests.swift in Sources */, DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Mastodon/Mastodon.xctestplan b/Mastodon/Mastodon.xctestplan index bb3dcf385..8c6261c87 100644 --- a/Mastodon/Mastodon.xctestplan +++ b/Mastodon/Mastodon.xctestplan @@ -8,6 +8,7 @@ } }, { + "enabled" : false, "id" : "06DB4A21-6DDF-454F-ABEE-E77DD7AAA146", "name" : "Arabic", "options" : { @@ -15,6 +16,7 @@ } }, { + "enabled" : false, "id" : "D95412BD-821C-4FE5-9DA6-8D77C0972B72", "name" : "Catalan", "options" : { @@ -22,6 +24,7 @@ } }, { + "enabled" : false, "id" : "EE9219EA-2011-48B5-A475-309C99F91D6D", "name" : "Chinese, Simplified", "options" : { @@ -29,6 +32,7 @@ } }, { + "enabled" : false, "id" : "6FBBAF31-D445-482E-B67B-271F8216AEB6", "name" : "Dutch", "options" : { @@ -36,6 +40,7 @@ } }, { + "enabled" : false, "id" : "45A93B8D-54C5-4906-8AC6-5B6FE561CA25", "name" : "French", "options" : { @@ -43,6 +48,7 @@ } }, { + "enabled" : false, "id" : "8344111A-3025-4CA0-838C-AF94EBA4D4BE", "name" : "German", "options" : { @@ -50,6 +56,7 @@ } }, { + "enabled" : false, "id" : "4EE64E47-F9E5-4189-8571-20D29941F854", "name" : "Japanese", "options" : { @@ -57,6 +64,7 @@ } }, { + "enabled" : false, "id" : "746F1EBA-E12B-40C4-85C6-A14DC61A180B", "name" : "Spanish", "options" : { @@ -64,6 +72,7 @@ } }, { + "enabled" : false, "id" : "EDA29FF5-1F0E-451A-863D-0899CE07CB09", "name" : "Spanish (Latin America)", "options" : { @@ -76,7 +85,11 @@ }, "testTargets" : [ { - "enabled" : false, + "skippedTests" : [ + "MastodonTests", + "MastodonTests\/testConnectOnion()", + "MastodonTests\/testWebFinger()" + ], "target" : { "containerPath" : "container:Mastodon.xcodeproj", "identifier" : "DB427DE725BAA00100D1B89D", diff --git a/MastodonSDK/Sources/MastodonUI/Helper/MastodonMetricFormatter.swift b/MastodonSDK/Sources/MastodonUI/Helper/MastodonMetricFormatter.swift index 50fca8cc9..52eee5862 100644 --- a/MastodonSDK/Sources/MastodonUI/Helper/MastodonMetricFormatter.swift +++ b/MastodonSDK/Sources/MastodonUI/Helper/MastodonMetricFormatter.swift @@ -7,8 +7,30 @@ import Foundation -public final class MastodonMetricFormatter: Formatter { +enum DecimalUnit: Int { + case one = 1 + case ten = 10 + case hundred = 100 + case thousand = 1_000 + case million = 1_000_000 + case billion = 1_000_000_000 + case trillion = 1_000_000_000_000 + + var asInt: Int { + self.rawValue + } + + var asDouble: Double { + Double(self.rawValue) + } +} + +public final class MastodonMetricFormatter: Formatter { + + private let ten_thousands = DecimalUnit.thousand.asInt * 10 + private let ten_millions = DecimalUnit.million.asInt * 10 + public func string(from number: Int) -> String? { let isPositive = number >= 0 let symbol = isPositive ? "" : "-" @@ -19,20 +41,30 @@ public final class MastodonMetricFormatter: Formatter { let metric: String switch value { - case 0..<1000: // 0 ~ 1K - metric = String(value) - case 1000..<10000: // 1K ~ 10K - numberFormatter.maximumFractionDigits = 1 - let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) - metric = string + "K" - case 10000..<1000000: // 10K ~ 1M + case 0 ..< DecimalUnit.thousand.asInt: // 0 ~ 1K numberFormatter.maximumFractionDigits = 0 - let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000.0)) ?? String(value / 1000) + let string = numberFormatter.string(from: NSNumber(value: value)) ?? String(value) + metric = string + case DecimalUnit.thousand.asInt ..< DecimalUnit.million.asInt: // 1K ~ 1M + numberFormatter.maximumFractionDigits = value < ten_thousands ? 1 : 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / DecimalUnit.thousand.asDouble)) ?? + String(value / DecimalUnit.thousand.asInt) metric = string + "K" - default: - numberFormatter.maximumFractionDigits = 0 - let string = numberFormatter.string(from: NSNumber(value: Double(value) / 1000000.0)) ?? String(value / 1000000) + case DecimalUnit.million.asInt ..< DecimalUnit.billion.asInt: // 1M ~ 1B + numberFormatter.maximumFractionDigits = value < ten_millions ? 1 : 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / DecimalUnit.million.asDouble)) ?? + String(value / DecimalUnit.million.asInt) metric = string + "M" + case DecimalUnit.billion.asInt ..< DecimalUnit.trillion.asInt: // 1B ~ 1T + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / DecimalUnit.billion.asDouble)) ?? + String(value / DecimalUnit.billion.asInt) + metric = string + "B" + default: // > 1T + numberFormatter.maximumFractionDigits = 0 + let string = numberFormatter.string(from: NSNumber(value: Double(value) / DecimalUnit.trillion.asDouble)) ?? + String(value / DecimalUnit.trillion.asInt) + metric = string + "T" } return symbol + metric diff --git a/MastodonTests/MetricFormatterTests.swift b/MastodonTests/MetricFormatterTests.swift new file mode 100644 index 000000000..b42b46893 --- /dev/null +++ b/MastodonTests/MetricFormatterTests.swift @@ -0,0 +1,126 @@ +// Copyright © 2024 Mastodon gGmbH. All rights reserved. + +import XCTest +@testable import MastodonUI + +class MetricFormatterTests: XCTestCase { + + func test_tensFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 12) + + XCTAssertEqual(value, "12") + } + + func test_hundredsFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 123) + + XCTAssertEqual(value, "123") + } + + func test_thousandOneFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 1001) + + XCTAssertEqual(value, "1K") + } + + func test_thousandFiftyFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 1050) + + XCTAssertEqual(value, "1K") + } + + func test_thousandNinetynineFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 1099) + + XCTAssertEqual(value, "1,1K") + } + + func test_thousandNinehundredFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 1900) + + XCTAssertEqual(value, "1,9K") + } + + func test_thousandsFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 1234) + + XCTAssertEqual(value, "1,2K") + } + + func test_sixThousandsFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 6666) + + XCTAssertEqual(value, "6,7K") + } + + func test_millionsFormat_oneTwoThreeMillion() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 1_234_567) + + XCTAssertEqual(value, "1,2M") + } + + func test_millionsFormat_exactlyTenMillion() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 10_000_000) + + XCTAssertEqual(value, "10M") + } + + func test_millionsFormat_twelveOneTwoThreeMillion() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 12_345_789) + + XCTAssertEqual(value, "12M") + } + + func test_billionsFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 10_000_000_000) + + XCTAssertEqual(value, "10B") + } + + func test_billionsFormat_oneTwoThreeBillion() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 12_345_678_912) + + XCTAssertEqual(value, "12B") + } + + func test_trillionsFormat() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 10_000_000_000_000) + + XCTAssertEqual(value, "10T") + } + + func test_trillionsFormat_oneTwoThreeTrillion() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 12_345_678_912_345) + + XCTAssertEqual(value, "12T") + } + + func test_trillionsFormat_oneTwoThree_youGottaBeKiddinMeTrillion() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 12_345_678_912_345_678) + + XCTAssertEqual(value, "12346T") + } + + func test_trillionsFormat_oneTwoThree_lastDigitBeforeIntegerOverflowTrillion() { + let formatter = MastodonMetricFormatter() + let value = formatter.string(from: 12_345_678_912_345_678_91) + + XCTAssertEqual(value, "1234568T") + } +}