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