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) + } +}