Fix digit corruption in Apple digit grouping for very large numbers

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXcrACM5sok3M1KQJ8kByy
This commit is contained in:
2026-09-11 15:11:53 +03:00
co-authored by Claude Sonnet 5
parent 4388350230
commit c45c4446e6
2 changed files with 64 additions and 2 deletions
@@ -2,14 +2,41 @@ package xyz.tyiu.satsprice.domain
import platform.Foundation.* 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 { actual fun localizedGroupedInteger(digits: String): String {
val formatter = NSNumberFormatter().apply { val formatter = NSNumberFormatter().apply {
numberStyle = NSNumberFormatterDecimalStyle numberStyle = NSNumberFormatterDecimalStyle
locale = NSLocale.currentLocale locale = NSLocale.currentLocale
usesGroupingSeparator = true usesGroupingSeparator = true
} }
val number = NSDecimalNumber(string = digits) val separator = formatter.groupingSeparator
return formatter.stringFromNumber(number) ?: digits 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<String>()
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 actual fun localizedDecimalSeparator(): String = NSLocale.currentLocale.decimalSeparator
@@ -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)
}
}