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:
@@ -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<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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user