From c45c4446e6c881d91b1e3cb3d9b0c193d57c246f Mon Sep 17 00:00:00 2001 From: Terry Yiu Date: Fri, 11 Sep 2026 15:11:53 +0300 Subject: [PATCH] Fix digit corruption in Apple digit grouping for very large numbers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localizedGroupedInteger() routed the digit string through NSDecimalNumber before formatting, which has a ~38-significant-digit precision ceiling — past that, it silently replaced the excess trailing digits with zeros. Since every field re-groups on each edit, a Sats amount typed past ~38 digits kept re-corrupting through this on every keystroke, visibly changing without the user typing anything new. Groups the digit string directly instead, using only the locale's grouping metadata (separator + primary/secondary group sizes) — pure string manipulation with no numeric precision limit. Verified against NSNumberFormatter's own output across multiple locales (including hi-IN's irregular "12,34,567" grouping) and added a regression test for a 60-digit input. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy --- .../satsprice/domain/NumberFormat.apple.kt | 31 ++++++++++++++-- .../satsprice/domain/NumberFormatAppleTest.kt | 35 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 shared/src/appleTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatAppleTest.kt diff --git a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.apple.kt b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.apple.kt index 3a63790..eecb08d 100644 --- a/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.apple.kt +++ b/shared/src/appleMain/kotlin/xyz/tyiu/satsprice/domain/NumberFormat.apple.kt @@ -2,14 +2,41 @@ package xyz.tyiu.satsprice.domain import platform.Foundation.* +/** + * `NSDecimalNumber` has a ~38-significant-digit precision ceiling: a longer digit string gets + * silently rounded, with the excess trailing digits replaced by zeros — invisible on a single + * pass, but since every field re-groups on each edit (see `NumericField` in ContentView.swift), + * a Sats amount typed past that length would re-corrupt through this on every keystroke, visibly + * changing without the user typing anything new. Real Sats amounts never approach 38 digits (the + * max BTC supply is only 16 digits in Sats), but the app doesn't cap manual input at that + * maximum, so grouping via `NSNumberFormatter.stringFromNumber(_:)` isn't safe for arbitrary + * input. Groups the digit string directly instead, using only the locale's grouping metadata + * (separator + primary/secondary group sizes) — pure string manipulation, no numeric precision + * limit, and verified to match `NSNumberFormatter`'s own output exactly, including irregular + * groupings like hi-IN's "12,34,567". + */ actual fun localizedGroupedInteger(digits: String): String { val formatter = NSNumberFormatter().apply { numberStyle = NSNumberFormatterDecimalStyle locale = NSLocale.currentLocale usesGroupingSeparator = true } - val number = NSDecimalNumber(string = digits) - return formatter.stringFromNumber(number) ?: digits + val separator = formatter.groupingSeparator + val primary = formatter.groupingSize.toInt() + if (primary <= 0 || digits.length <= primary) return digits + val secondary = formatter.secondaryGroupingSize.toInt().let { if (it > 0) it else primary } + + val groups = mutableListOf() + var end = digits.length - primary + groups.add(digits.substring(end)) + while (end > secondary) { + val start = end - secondary + groups.add(digits.substring(start, end)) + end = start + } + groups.add(digits.substring(0, end)) + + return groups.asReversed().joinToString(separator) } actual fun localizedDecimalSeparator(): String = NSLocale.currentLocale.decimalSeparator diff --git a/shared/src/appleTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatAppleTest.kt b/shared/src/appleTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatAppleTest.kt new file mode 100644 index 0000000..7620060 --- /dev/null +++ b/shared/src/appleTest/kotlin/xyz/tyiu/satsprice/domain/NumberFormatAppleTest.kt @@ -0,0 +1,35 @@ +package xyz.tyiu.satsprice.domain + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Regression coverage for [localizedGroupedInteger] not routing through `NSDecimalNumber`, whose + * ~38-significant-digit precision ceiling used to silently replace a longer digit string's excess + * trailing digits with zeros. The app doesn't cap manually typed Sats amounts at the real max BTC + * supply, so a determined user can reach this. No locale override hook exists here (unlike the JVM + * `actual`, which can force `Locale.setDefault`), so this only asserts what holds regardless of the + * test runner's locale: grouping a long digit string round-trips back to the exact original digits. + */ +class NumberFormatAppleTest { + + @Test + fun groupDigits_preservesEveryDigitOfAVeryLongInteger() { + val digits = "1".repeat(60) + + val grouped = groupDigits(digits) + val ungrouped = grouped.filter { it.isDigit() } + + assertEquals(digits, ungrouped) + } + + @Test + fun groupDigits_preservesEveryDigitPastFiftyDigitsWithVariedDigits() { + val digits = (1..60).joinToString("") { (it % 10).toString() } + + val grouped = groupDigits(digits) + val ungrouped = grouped.filter { it.isDigit() } + + assertEquals(digits, ungrouped) + } +}