From 2f039e71eae4c627bd6c9809f29f599daec6fa62 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 13:57:45 -0400 Subject: [PATCH 1/2] fix(tips): abbreviate tip presets that run past three digits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four chips share a row in the Send a Tip sheet, and a preset is a converted USD tier, so in a small-unit currency every tier is four digits or more: ARS renders $1,400 / $35,000 / $1,400,000, which `Text` then shrinks to half size to fit. `FiatAmount.formattedAbbreviated(minimumFractionDigits:)` keeps the figure to three digits, scaling past 999 with a K/M/B suffix and keeping a decimal only while the scaled figure is a single digit: $1.4K, $35K, $1.4M. It routes through the same `NumberFormatter.fiat` path as `formatted`, so the symbol, grouping, and negative form (-$1.5K) are unchanged. `CompactCurrencyFormatStyle` — the market-cap style in Currency Discovery — was a second abbreviation rule built on ICU's `.compactName`. It is now a `FormatStyle` entry point onto this one. That agreed on every case its tests covered except an exact half: $10.5M reads $11M rather than $10M, because display rounding here is half-up like everywhere else in the app. --- .../Core/Screens/Main/Tips/SendTipSheet.swift | 5 +- .../CompactCurrencyFormatStyle.swift | 20 +++--- .../FlipcashCore/Models/FiatAmount.swift | 52 +++++++++++++++ .../CompactCurrencyFormatStyleTests.swift | 7 ++- FlipcashTests/FiatAmountDisplayTests.swift | 63 +++++++++++++++++++ 5 files changed, 133 insertions(+), 14 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift b/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift index 036b28515..a7fcef010 100644 --- a/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift @@ -76,10 +76,13 @@ struct SendTipSheet: View { // MARK: - Chips - + /// Chips sit four to a row, so a preset is abbreviated past three digits + /// ("$1.5K") rather than shrunk to fit — presets run large in currencies + /// with small units. private func presetChip(_ tier: TipSelection) -> some View { let amount = tipFlow.amount(for: tier) return TipAmountChip( - title: amount.map { FiatAmount(value: $0, currency: displayCurrency).formatted(minimumFractionDigits: 0) } ?? "–", + title: amount.map { FiatAmount(value: $0, currency: displayCurrency).formattedAbbreviated(minimumFractionDigits: 0) } ?? "–", isSelected: tipFlow.selection == tier ) { tipFlow.selection = tier diff --git a/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift b/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift index 19e0c8c68..c5e7bdd0a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift @@ -5,11 +5,12 @@ import Foundation -/// A `FormatStyle` that formats numeric values as compact currency strings. +/// A `FormatStyle` that formats numeric values as compact currency strings — +/// the `FormatStyle` entry point onto ``FiatAmount/formattedAbbreviated(minimumFractionDigits:)``, +/// for the `Double` figures (market caps, deltas) SwiftUI formats inline. /// -/// Uses the system's `.compactName` notation for large numbers and prepends -/// the currency symbol from `CurrencyCode`. Values below 100,000 are formatted -/// as whole numbers with grouping separators. +/// Sub-unit precision is dropped before formatting: at this scale it is noise, +/// and a market cap reads `$200`, not `$200.17`. /// /// Usage with SwiftUI `Text`: /// ```swift @@ -34,20 +35,15 @@ public struct CompactCurrencyFormatStyle: FormatStyle { } public func format(_ value: Double) -> String { - let symbol = currencyCode.singleCharacterCurrencySymbols ?? "" - let whole = Int(value) - // The sign belongs outside the symbol ("-$12K"), so format the magnitude - // and prepend the minus ourselves rather than letting it land after the symbol. - let sign = whole < 0 ? "-" : "" - let compact = whole.magnitude.formatted(.number.notation(.compactName)) - return "\(sign)\(symbol)\(compact)" + FiatAmount(value: Decimal(Int(value)), currency: currencyCode) + .formattedAbbreviated(minimumFractionDigits: 0) } } // MARK: - FormatStyle Extension - extension FormatStyle where Self == CompactCurrencyFormatStyle { - /// Formats a number as a compact currency string (e.g. `$1M`, `$100K`, `$99,999`). + /// Formats a number as a compact currency string (e.g. `$1M`, `$100K`, `$999`). public static func compactCurrency(code: CurrencyCode) -> CompactCurrencyFormatStyle { CompactCurrencyFormatStyle(code: code) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift index 7387092ba..60c3a6502 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift @@ -112,6 +112,58 @@ extension FiatAmount { } } +// MARK: - Abbreviation - + +extension FiatAmount { + + /// The scales an abbreviated figure steps through, smallest first. + private static let abbreviationSuffixes = ["K", "M", "B"] + + /// Format for a fixed-width slot where only three digits fit. Up to `$999` + /// this is plain ``formatted(minimumFractionDigits:suffix:)``; past it the + /// figure is scaled and suffixed — `$1.5K`, `$15K`, `$150K`, `$2.4M`, `$1B`. + /// The scaled figure keeps a decimal only while it is a single digit, so the + /// number never runs past three characters. + /// + /// The scale is chosen from the value, not the currency, so a currency whose + /// natural amounts are large (¥, Rp) abbreviates on the same rule. Rounding + /// is half-up like every other displayed figure, so `$10.5M` reads `$11M`. + /// + /// This is the one abbreviation rule; ``CompactCurrencyFormatStyle`` is the + /// `FormatStyle` entry point onto it. + public func formattedAbbreviated(minimumFractionDigits: Int? = nil) -> String { + guard abs(value) >= 1000 else { + return formatted(minimumFractionDigits: minimumFractionDigits) + } + + var scaled = value / 1000 + var scale = 0 + while abs(scaled) >= 1000, scale < Self.abbreviationSuffixes.count - 1 { + scaled /= 1000 + scale += 1 + } + + var fractionDigits = abs(scaled) < 10 ? 1 : 0 + var rounded = scaled.rounded(to: fractionDigits) + // 999,999 scales to 999.999K, which rounds back into a fourth digit; + // carry it up a scale ("$1M") rather than print "$1,000K". Values past + // 999B have nowhere left to carry and stay in `B`. + if abs(rounded) >= 1000, scale < Self.abbreviationSuffixes.count - 1 { + scale += 1 + fractionDigits = 1 + rounded = (scaled / 1000).rounded(to: fractionDigits) + } + + return NumberFormatter.fiat( + currency: currency, + minimumFractionDigits: 0, + maximumFractionDigits: fractionDigits, + truncated: false, + suffix: Self.abbreviationSuffixes[scale], + ).string(from: rounded as NSDecimalNumber)! + } +} + // MARK: - Display Threshold - extension FiatAmount { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift index c5487e5e2..f27dac78d 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift @@ -17,7 +17,9 @@ struct CompactCurrencyFormatStyleTests { #expect(format.format(1_000_000) == "$1M") #expect(format.format(1_029_331.15) == "$1M") #expect(format.format(1_299_217.10) == "$1.3M") - #expect(format.format(10_500_000) == "$10M") + // Half-up, like every other displayed figure — ICU's compact notation + // rounded this half-even to "$10M". + #expect(format.format(10_500_000) == "$11M") } @Test("Thousands are formatted with K suffix") @@ -31,7 +33,10 @@ struct CompactCurrencyFormatStyleTests { func smallValues() { #expect(format.format(99_999) == "$100K") #expect(format.format(1_234) == "$1.2K") + // Under a thousand the figure is shown whole — sub-unit precision is + // dropped rather than rounded into the display. #expect(format.format(200.17) == "$200") + #expect(format.format(999.99) == "$999") } @Test("Negative values put the sign before the currency symbol") diff --git a/FlipcashTests/FiatAmountDisplayTests.swift b/FlipcashTests/FiatAmountDisplayTests.swift index ac200280c..36de96c34 100644 --- a/FlipcashTests/FiatAmountDisplayTests.swift +++ b/FlipcashTests/FiatAmountDisplayTests.swift @@ -152,3 +152,66 @@ struct FiatAmountFormattedTests { ) } } + +@Suite("FiatAmount Abbreviated") +struct FiatAmountAbbreviatedTests { + + @Test( + "formattedAbbreviated(minimumFractionDigits:) keeps the figure to three digits", + arguments: [ + // currency, value, minFrac, expected + + // Under a thousand — plain formatting, untouched. + (CurrencyCode.usd, Decimal(1), Int?(0), "$1"), + (.usd, Decimal(999), Int?(0), "$999"), + (.usd, Decimal(string: "2.50")!, nil, "$2.50"), + (.usd, Decimal(999), nil, "$999.00"), + + // Thousands — a decimal only while the figure is a single digit. + (.usd, Decimal(1_000), Int?(0), "$1K"), + (.usd, Decimal(1_500), Int?(0), "$1.5K"), + (.usd, Decimal(1_550), Int?(0), "$1.6K"), // halfUp + (.usd, Decimal(9_999), Int?(0), "$10K"), + (.usd, Decimal(15_000), Int?(0), "$15K"), + (.usd, Decimal(150_000), Int?(0), "$150K"), + (.usd, Decimal(999_499), Int?(0), "$999K"), + + // The rounding carry: 999.5K is a fourth digit, so it becomes $1M. + (.usd, Decimal(999_500), Int?(0), "$1M"), + + // Millions and billions. + (.usd, Decimal(2_400_000), Int?(0), "$2.4M"), + (.usd, Decimal(25_000_000), Int?(0), "$25M"), + (.usd, Decimal(1_000_000_000), Int?(0), "$1B"), + (.usd, Decimal(1_250_000_000), Int?(0), "$1.3B"), + + // Past the largest scale there is nowhere to carry, so it stays in B. + (.usd, Decimal(1_000_000_000_000), Int?(0), "$1,000B"), + + // A zero-decimal currency abbreviates on the value, not its precision. + (.jpy, Decimal(1_500), Int?(0), "¥1.5K"), + (.jpy, Decimal(250_000), Int?(0), "¥250K"), + + // Small-unit currencies, where every everyday amount is four digits + // or more — the case the tip presets abbreviate for. ARS values are + // roughly the $1 / $25 / $1,000 tiers. + (.ars, Decimal(1_400), Int?(0), "$1.4K"), + (.ars, Decimal(35_000), Int?(0), "$35K"), + (.ars, Decimal(1_400_000), Int?(0), "$1.4M"), + (.cop, Decimal(97_500), Int?(0), "$98K"), + (.vnd, Decimal(650_000), Int?(0), "₫650K"), + // IDR has no single-character symbol, so it formats bare. + (.idr, Decimal(400_000), Int?(0), "400K"), + + // Negatives keep the minus ahead of the symbol. + (.usd, Decimal(-1_500), Int?(0), "-$1.5K"), + (.usd, Decimal(-2_400_000), Int?(0), "-$2.4M"), + ] as [(CurrencyCode, Decimal, Int?, String)] + ) + func formattedAbbreviated(currency: CurrencyCode, value: Decimal, minimumFractionDigits: Int?, expected: String) { + #expect( + FiatAmount(value: value, currency: currency) + .formattedAbbreviated(minimumFractionDigits: minimumFractionDigits) == expected + ) + } +} From 36be7e554fb05683a87bee6f48370823b3e979c5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 14:49:30 -0400 Subject: [PATCH 2/2] fix(tips): abbreviate on Android's rule, digit for digit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both apps now abbreviate the tip presets, but not to the same string: 12,345 pesos read $12.3K on Android and $12K here, because this rule kept a decimal only while the scaled figure was a single digit instead of spending the whole three-digit budget. Android's is the rule that answers "three digits", so this takes it. formattedAbbreviated(maxDigits:) now mirrors Fiat.abbreviated(maxDigits:) step for step — round to maxDigits significant digits, pick the largest scale the result clears, spend what is left on decimals. That adds the T scale, where 1e12 used to print $1,000B, and carries 999.99 up to $1K rather than showing five digits. formattedDroppingZeroFraction() is Android's FormattingRule.Truncated: it formats the amounts under the first scale and replaces the sheet's local customTitle helper. The custom chip abbreviates too, as Android's shared slot does, and both chips hand VoiceOver the unabbreviated amount the way Android's content description does. Market caps move with the shared rule, since Currency Discovery formats through it: $1,029,331 reads $1.03M rather than $1M. --- .../Core/Screens/Main/Tips/SendTipSheet.swift | 26 +++-- .../CompactCurrencyFormatStyle.swift | 8 +- .../FlipcashCore/Models/FiatAmount.swift | 98 +++++++++++------ .../CompactCurrencyFormatStyleTests.swift | 14 +-- FlipcashTests/FiatAmountDisplayTests.swift | 100 +++++++++--------- 5 files changed, 137 insertions(+), 109 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift b/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift index a7fcef010..bd471fcd7 100644 --- a/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift @@ -76,13 +76,14 @@ struct SendTipSheet: View { // MARK: - Chips - - /// Chips sit four to a row, so a preset is abbreviated past three digits + /// Chips sit four to a row, so an amount is abbreviated past three digits /// ("$1.5K") rather than shrunk to fit — presets run large in currencies - /// with small units. + /// with small units. VoiceOver still reads the amount in full. private func presetChip(_ tier: TipSelection) -> some View { - let amount = tipFlow.amount(for: tier) + let amount = tipFlow.amount(for: tier).map { FiatAmount(value: $0, currency: displayCurrency) } return TipAmountChip( - title: amount.map { FiatAmount(value: $0, currency: displayCurrency).formattedAbbreviated(minimumFractionDigits: 0) } ?? "–", + title: amount?.formattedAbbreviated() ?? "–", + accessibilityLabel: amount?.formattedDroppingZeroFraction(), isSelected: tipFlow.selection == tier ) { tipFlow.selection = tier @@ -94,8 +95,10 @@ struct SendTipSheet: View { /// The fourth slot: "…" until a custom amount is set, then that amount. /// Tapping always opens the amount entry, so a set amount can be changed. private var customChip: some View { - TipAmountChip( - title: tipFlow.amount(for: .custom).map(customTitle) ?? "…", + let amount = tipFlow.amount(for: .custom).map { FiatAmount(value: $0, currency: displayCurrency) } + return TipAmountChip( + title: amount?.formattedAbbreviated() ?? "…", + accessibilityLabel: amount?.formattedDroppingZeroFraction() ?? "Enter a custom amount", isSelected: tipFlow.selection == .custom ) { localSheet = .customAmount @@ -103,14 +106,6 @@ struct SendTipSheet: View { .accessibilityIdentifier("tip-custom-chip") } - /// Formats a custom amount, dropping the fraction only when it's whole - /// (`$11`, not `$11.00`) while keeping real fractions intact (`$2.50`). - private func customTitle(_ amount: Decimal) -> String { - let isWhole = amount == amount.rounded(to: 0) - return FiatAmount(value: amount, currency: displayCurrency) - .formatted(minimumFractionDigits: isWhole ? 0 : nil) - } - // MARK: - Currency - /// The tip currency, shown as a pill in the sheet header (Figma 8966-2649): @@ -134,6 +129,8 @@ struct SendTipSheet: View { private struct TipAmountChip: View { let title: String + /// Read instead of the abbreviated title; defaults to the title itself. + var accessibilityLabel: String? let isSelected: Bool let action: () -> Void @@ -150,6 +147,7 @@ private struct TipAmountChip: View { .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) } .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel ?? title) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift b/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift index c5e7bdd0a..86e96b508 100644 --- a/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift +++ b/FlipcashCore/Sources/FlipcashCore/Formatters/CompactCurrencyFormatStyle.swift @@ -6,7 +6,7 @@ import Foundation /// A `FormatStyle` that formats numeric values as compact currency strings — -/// the `FormatStyle` entry point onto ``FiatAmount/formattedAbbreviated(minimumFractionDigits:)``, +/// the `FormatStyle` entry point onto ``FiatAmount/formattedAbbreviated(maxDigits:)``, /// for the `Double` figures (market caps, deltas) SwiftUI formats inline. /// /// Sub-unit precision is dropped before formatting: at this scale it is noise, @@ -15,7 +15,7 @@ import Foundation /// Usage with SwiftUI `Text`: /// ```swift /// Text(1_029_331.15, format: .compactCurrency(code: .usd)) -/// // → "$1M" +/// // → "$1.03M" /// /// Text(690_272.45, format: .compactCurrency(code: .usd)) /// // → "$690K" @@ -24,7 +24,7 @@ import Foundation /// // → "$100K" /// /// Text(-12_400, format: .compactCurrency(code: .usd)) -/// // → "-$12K" +/// // → "-$12.4K" /// ``` public struct CompactCurrencyFormatStyle: FormatStyle { @@ -36,7 +36,7 @@ public struct CompactCurrencyFormatStyle: FormatStyle { public func format(_ value: Double) -> String { FiatAmount(value: Decimal(Int(value)), currency: currencyCode) - .formattedAbbreviated(minimumFractionDigits: 0) + .formattedAbbreviated() } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift index 60c3a6502..586bcef8d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift @@ -110,57 +110,85 @@ extension FiatAmount { suffix: suffix, ).string(from: value as NSDecimalNumber)! } + + /// Format for display, dropping the fraction when the amount is whole (`$11`, + /// not `$11.00`) and keeping it when it isn't (`$2.50`). This is how amounts + /// are shown in fixed-width controls, and the unabbreviated form the same + /// controls hand to VoiceOver. + /// + /// Paired with Android's `Fiat.FormattingRule.Truncated`. + public func formattedDroppingZeroFraction() -> String { + formatted(minimumFractionDigits: value == value.rounded(to: 0) ? 0 : nil) + } } // MARK: - Abbreviation - extension FiatAmount { - /// The scales an abbreviated figure steps through, smallest first. - private static let abbreviationSuffixes = ["K", "M", "B"] - - /// Format for a fixed-width slot where only three digits fit. Up to `$999` - /// this is plain ``formatted(minimumFractionDigits:suffix:)``; past it the - /// figure is scaled and suffixed — `$1.5K`, `$15K`, `$150K`, `$2.4M`, `$1B`. - /// The scaled figure keeps a decimal only while it is a single digit, so the - /// number never runs past three characters. + /// The scales an abbreviated figure steps through, ascending; the largest one + /// the amount clears is the one it is printed in. + private static let abbreviationScales: [(scale: Decimal, suffix: String)] = [ + (1_000, "K"), + (1_000_000, "M"), + (1_000_000_000, "B"), + (1_000_000_000_000, "T"), + ] + + /// The amount formatted to fit a fixed-width control, capped at `maxDigits` + /// digits: anything under the first scale is formatted as usual, and larger + /// amounts are scaled to K/M/B/T with only as many decimals as the cap leaves + /// room for — trailing zeros dropped. /// - /// The scale is chosen from the value, not the currency, so a currency whose - /// natural amounts are large (¥, Rp) abbreviates on the same rule. Rounding - /// is half-up like every other displayed figure, so `$10.5M` reads `$11M`. + /// The cap is what keeps a localized amount inside its button: a $20 tip stays + /// `$20`, but the same tip in rupiah is 332,000, which shows as `332K` rather + /// than overflowing. The scale is chosen from the value, not the currency, so a + /// currency whose everyday amounts are large abbreviates on the same rule. /// - /// This is the one abbreviation rule; ``CompactCurrencyFormatStyle`` is the - /// `FormatStyle` entry point onto it. - public func formattedAbbreviated(minimumFractionDigits: Int? = nil) -> String { - guard abs(value) >= 1000 else { - return formatted(minimumFractionDigits: minimumFractionDigits) - } - - var scaled = value / 1000 - var scale = 0 - while abs(scaled) >= 1000, scale < Self.abbreviationSuffixes.count - 1 { - scaled /= 1000 - scale += 1 + /// Paired with Android's `Fiat.abbreviated(maxDigits:)` — the two produce the + /// same string for the same amount. ``CompactCurrencyFormatStyle`` is the + /// `FormatStyle` entry point onto this. + public func formattedAbbreviated(maxDigits: Int = 3) -> String { + guard value != 0 else { return formattedDroppingZeroFraction() } + + // Round to `maxDigits` significant digits before picking the scale, so a + // value that carries into the next one (999,999 → 1M) is scaled by the one + // it lands in rather than printed as "1,000K". + let rounded = value.rounded(to: maxDigits - 1 - value.leadingExponent) + + guard let step = Self.abbreviationScales.last(where: { abs(rounded) >= $0.scale }) else { + return formattedDroppingZeroFraction() } - var fractionDigits = abs(scaled) < 10 ? 1 : 0 - var rounded = scaled.rounded(to: fractionDigits) - // 999,999 scales to 999.999K, which rounds back into a fourth digit; - // carry it up a scale ("$1M") rather than print "$1,000K". Values past - // 999B have nowhere left to carry and stay in `B`. - if abs(rounded) >= 1000, scale < Self.abbreviationSuffixes.count - 1 { - scale += 1 - fractionDigits = 1 - rounded = (scaled / 1000).rounded(to: fractionDigits) - } + let scaled = rounded / step.scale + let wholeDigits = scaled.leadingExponent + 1 + let fractionDigits = max(0, maxDigits - wholeDigits) return NumberFormatter.fiat( currency: currency, minimumFractionDigits: 0, maximumFractionDigits: fractionDigits, truncated: false, - suffix: Self.abbreviationSuffixes[scale], - ).string(from: rounded as NSDecimalNumber)! + suffix: step.suffix, + ).string(from: scaled as NSDecimalNumber)! + } +} + +private extension Decimal { + /// The power of ten of the leading digit — `floor(log10(abs(self)))`. Zero has + /// no leading digit and answers `0`. + var leadingExponent: Int { + var magnitude = abs(self) + var exponent = 0 + while magnitude >= 10 { + magnitude /= 10 + exponent += 1 + } + while magnitude > 0, magnitude < 1 { + magnitude *= 10 + exponent -= 1 + } + return exponent } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift index f27dac78d..5a572d848 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/CompactCurrencyFormatStyleTests.swift @@ -15,11 +15,11 @@ struct CompactCurrencyFormatStyleTests { @Test("Millions are formatted with M suffix") func millions() { #expect(format.format(1_000_000) == "$1M") - #expect(format.format(1_029_331.15) == "$1M") + // Three digits, so the scaled figure keeps two decimals here and one at + // 10.5M — the cap decides, not the scale. + #expect(format.format(1_029_331.15) == "$1.03M") #expect(format.format(1_299_217.10) == "$1.3M") - // Half-up, like every other displayed figure — ICU's compact notation - // rounded this half-even to "$10M". - #expect(format.format(10_500_000) == "$11M") + #expect(format.format(10_500_000) == "$10.5M") } @Test("Thousands are formatted with K suffix") @@ -32,7 +32,7 @@ struct CompactCurrencyFormatStyleTests { @Test("Small values use compact notation") func smallValues() { #expect(format.format(99_999) == "$100K") - #expect(format.format(1_234) == "$1.2K") + #expect(format.format(1_234) == "$1.23K") // Under a thousand the figure is shown whole — sub-unit precision is // dropped rather than rounded into the display. #expect(format.format(200.17) == "$200") @@ -41,7 +41,7 @@ struct CompactCurrencyFormatStyleTests { @Test("Negative values put the sign before the currency symbol") func negativeValues() { - #expect(format.format(-12_400) == "-$12K") + #expect(format.format(-12_400) == "-$12.4K") #expect(format.format(-6_600) == "-$6.6K") #expect(format.format(-384) == "-$384") #expect(format.format(-1_299_217.10) == "-$1.3M") @@ -61,6 +61,6 @@ struct CompactCurrencyFormatStyleTests { @Test("Works with Text format syntax") func formatStyleExtension() { let result = 1_029_331.15.formatted(.compactCurrency(code: .usd)) - #expect(result == "$1M") + #expect(result == "$1.03M") } } diff --git a/FlipcashTests/FiatAmountDisplayTests.swift b/FlipcashTests/FiatAmountDisplayTests.swift index 36de96c34..8190b9b8b 100644 --- a/FlipcashTests/FiatAmountDisplayTests.swift +++ b/FlipcashTests/FiatAmountDisplayTests.swift @@ -157,61 +157,63 @@ struct FiatAmountFormattedTests { struct FiatAmountAbbreviatedTests { @Test( - "formattedAbbreviated(minimumFractionDigits:) keeps the figure to three digits", + "formattedAbbreviated(maxDigits:) caps the figure at three digits", arguments: [ - // currency, value, minFrac, expected - - // Under a thousand — plain formatting, untouched. - (CurrencyCode.usd, Decimal(1), Int?(0), "$1"), - (.usd, Decimal(999), Int?(0), "$999"), - (.usd, Decimal(string: "2.50")!, nil, "$2.50"), - (.usd, Decimal(999), nil, "$999.00"), - - // Thousands — a decimal only while the figure is a single digit. - (.usd, Decimal(1_000), Int?(0), "$1K"), - (.usd, Decimal(1_500), Int?(0), "$1.5K"), - (.usd, Decimal(1_550), Int?(0), "$1.6K"), // halfUp - (.usd, Decimal(9_999), Int?(0), "$10K"), - (.usd, Decimal(15_000), Int?(0), "$15K"), - (.usd, Decimal(150_000), Int?(0), "$150K"), - (.usd, Decimal(999_499), Int?(0), "$999K"), - - // The rounding carry: 999.5K is a fourth digit, so it becomes $1M. - (.usd, Decimal(999_500), Int?(0), "$1M"), - - // Millions and billions. - (.usd, Decimal(2_400_000), Int?(0), "$2.4M"), - (.usd, Decimal(25_000_000), Int?(0), "$25M"), - (.usd, Decimal(1_000_000_000), Int?(0), "$1B"), - (.usd, Decimal(1_250_000_000), Int?(0), "$1.3B"), - - // Past the largest scale there is nowhere to carry, so it stays in B. - (.usd, Decimal(1_000_000_000_000), Int?(0), "$1,000B"), + // currency, value, expected + + // Under the first scale — formatted as usual, fraction dropped when whole. + (CurrencyCode.usd, Decimal(5), "$5"), + (.usd, Decimal(999), "$999"), + (.usd, Decimal(string: "12.50")!, "$12.50"), + + // Thousands, with the cap deciding how many decimals survive. + (.usd, Decimal(1_000), "$1K"), + (.usd, Decimal(1_500), "$1.5K"), + (.usd, Decimal(1_234), "$1.23K"), + (.usd, Decimal(12_345), "$12.3K"), + (.usd, Decimal(123_456), "$123K"), + + // Millions, billions and trillions get their own suffix. + (.usd, Decimal(1_000_000), "$1M"), + (.usd, Decimal(2_500_000), "$2.5M"), + (.usd, Decimal(1_000_000_000), "$1B"), + (.usd, Decimal(1_000_000_000_000), "$1T"), + + // An amount that rounds into the next scale is printed in that scale. + (.usd, Decimal(999_999), "$1M"), + + (.usd, Decimal(0), "$0"), // A zero-decimal currency abbreviates on the value, not its precision. - (.jpy, Decimal(1_500), Int?(0), "¥1.5K"), - (.jpy, Decimal(250_000), Int?(0), "¥250K"), - - // Small-unit currencies, where every everyday amount is four digits - // or more — the case the tip presets abbreviate for. ARS values are - // roughly the $1 / $25 / $1,000 tiers. - (.ars, Decimal(1_400), Int?(0), "$1.4K"), - (.ars, Decimal(35_000), Int?(0), "$35K"), - (.ars, Decimal(1_400_000), Int?(0), "$1.4M"), - (.cop, Decimal(97_500), Int?(0), "$98K"), - (.vnd, Decimal(650_000), Int?(0), "₫650K"), + (.jpy, Decimal(750), "¥750"), + (.jpy, Decimal(3_000), "¥3K"), + + // Small-unit currencies, where every everyday amount is four digits or + // more — the case the tip presets abbreviate for. The ARS figures are + // the $5 / $10 / $20 tiers in pesos. + (.ars, Decimal(7_500), "$7.5K"), + (.ars, Decimal(15_000), "$15K"), + (.ars, Decimal(30_000), "$30K"), + (.ars, Decimal(25_500), "$25.5K"), + (.ars, Decimal(123_456), "$123K"), + (.cop, Decimal(97_500), "$97.5K"), + (.vnd, Decimal(126_000), "₫126K"), + (.vnd, Decimal(1_315_000), "₫1.32M"), // IDR has no single-character symbol, so it formats bare. - (.idr, Decimal(400_000), Int?(0), "400K"), + (.idr, Decimal(332_000), "332K"), + (.idr, Decimal(500), "500"), // Negatives keep the minus ahead of the symbol. - (.usd, Decimal(-1_500), Int?(0), "-$1.5K"), - (.usd, Decimal(-2_400_000), Int?(0), "-$2.4M"), - ] as [(CurrencyCode, Decimal, Int?, String)] + (.usd, Decimal(-1_500), "-$1.5K"), + (.usd, Decimal(-2_400_000), "-$2.4M"), + ] as [(CurrencyCode, Decimal, String)] ) - func formattedAbbreviated(currency: CurrencyCode, value: Decimal, minimumFractionDigits: Int?, expected: String) { - #expect( - FiatAmount(value: value, currency: currency) - .formattedAbbreviated(minimumFractionDigits: minimumFractionDigits) == expected - ) + func formattedAbbreviated(currency: CurrencyCode, value: Decimal, expected: String) { + #expect(FiatAmount(value: value, currency: currency).formattedAbbreviated() == expected) + } + + @Test("A lower cap allows fewer digits") + func lowerCap() { + #expect(FiatAmount.usd(1_234).formattedAbbreviated(maxDigits: 2) == "$1.2K") } }