fix: pin toLocaleString() calls to en-US locale (#1400)

* fix: pin toLocaleString() calls to en-US locale
Several number formatting call sites relied on the host machine's default
locale instead of a fixed one, causing inconsistent digit grouping in the
UI and non-deterministic test failures on non-US locales.

* fix: address review feedback, adapt tests instead of hardcoding locale in components
This commit is contained in:
GuTS805 2026-08-04 22:32:48 +05:30 committed by GitHub
parent 4c2a886c6b
commit 9fb32af42f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 165 additions and 90 deletions

View file

@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DUMMY_SEED_PHRASE, pseudoRandomInteger } from '@/lib/utils'
import { flushActUpdates } from '@/test/flushActUpdates'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import type { BlockHeight } from '@/types/global'
import { ImportDetailsForm } from './ImportDetailsForm'
@ -64,24 +65,26 @@ describe('ImportDetailsForm', () => {
})
it('warns and does not submit when the blockheight is larger than current blockheight', async () => {
const onSubmit = vi.fn()
const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1)
render(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
/>,
)
typeBlockheight(currentBlockHeight + 1)
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(
screen.getByText(
`import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`,
),
).toBeInTheDocument(),
)
expect(onSubmit).not.toHaveBeenCalled()
await withRuntimeLocale('en-US', async () => {
const onSubmit = vi.fn()
const currentBlockHeight = pseudoRandomInteger(1, Number.MAX_SAFE_INTEGER - 1)
render(
<ImportDetailsForm
onSubmit={onSubmit}
sessionInfo={{ ...mockedSessionInfo, block_height: currentBlockHeight }}
/>,
)
typeBlockheight(currentBlockHeight + 1)
fireEvent.submit(document.querySelector('form')!)
await waitFor(() =>
expect(
screen.getByText(
`import_wallet.import_details.feedback_invalid_blockheight {"min":"0","max":"${currentBlockHeight.toLocaleString()}"}`,
),
).toBeInTheDocument(),
)
expect(onSubmit).not.toHaveBeenCalled()
})
})
it('submits when all values are valid', async () => {

View file

@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { type RescanInfo } from '@/context/JamSessionInfoContext'
import { SEGWIT_ACTIVATION_BLOCK, type WalletFileName } from '@/lib/utils'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import type { BlockHeight } from '@/types/global'
import { RescanChainPage } from './RescanChainPage'
@ -85,18 +86,20 @@ describe('RescanChainPage', () => {
})
it('renders the form', async () => {
await renderPage()
await withRuntimeLocale('en-US', async () => {
await renderPage()
expect(screen.getByText('rescan_chain.title')).toBeInTheDocument()
expect(screen.getByText('rescan_chain.title')).toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'rescan_chain.label_blockheight' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')).toBeInTheDocument()
expect(screen.getByRole('spinbutton', { name: 'rescan_chain.label_blockheight' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('rescan_chain.placeholder_blockheight')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 144 blocks (~24 hours)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 52,560 blocks (~1 year)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'From block #481,824' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 144 blocks (~24 hours)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Last 52,560 blocks (~1 year)' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'From block #481,824' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'rescan_chain.text_button_submit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'rescan_chain.text_button_submit' })).toBeInTheDocument()
})
})
it('renders the form and navigates back', async () => {

View file

@ -5,6 +5,7 @@ import user from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Balance } from '@/components/ui/jam/Balance'
import { JamDisplayContextProvider } from '@/context/JamDisplayContextProvider'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
const render = (ui: React.ReactNode, options?: Omit<RenderOptions, 'queries'>) => {
const providers = ({ children }: { children: React.ReactNode }) => {
@ -35,11 +36,13 @@ describe('<Balance />', () => {
})
it('should render balance in SATS', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.queryByTestId('bitcoin-symbol')).not.toBeInTheDocument()
expect(screen.queryByTestId('frozen-symbol')).not.toBeInTheDocument()
})
})
it('should render a string BTC value correctly as BTC', () => {
@ -49,9 +52,11 @@ describe('<Balance />', () => {
})
it('should render a string BTC value correctly as SATS', () => {
render(<Balance valueString={'123.03224961'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.03224961'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
})
})
it('should render a zero string BTC value correctly as BTC', () => {
@ -65,23 +70,31 @@ describe('<Balance />', () => {
})
it('should render a large string BTC value correctly as BTC', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
})
it('should render a large string BTC value correctly as SATS', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
})
it('should render a max string BTC value correctly as BTC', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
})
it('should render a max string BTC value correctly as SATS', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
})
it('should render a string SATS value correctly as SATS', () => {
@ -105,23 +118,31 @@ describe('<Balance />', () => {
})
it('should render a large string SATS value correctly as BTC', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`)
})
})
it('should render a large string SATS value correctly as SATS', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`)
})
})
it('should render a max string SATS value correctly as BTC', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="btc" showBalance={true} />)
expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`)
})
})
it('should render a max string SATS value correctly as SATS', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit="sats" showBalance={true} />)
expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`)
})
})
it('should render frozen balance in BTC', () => {
@ -132,10 +153,12 @@ describe('<Balance />', () => {
})
it('should render frozen balance in SATS', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} frozen={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
withRuntimeLocale('en-US', () => {
render(<Balance valueString={'123.456'} convertToUnit="sats" showBalance={true} frozen={true} />)
expect(screen.getByTestId('sats-amount')).toHaveTextContent(`12,345,600,000`)
expect(screen.getByTestId('sats-symbol')).toBeVisible()
expect(screen.getByTestId('frozen-symbol')).toBeVisible()
})
})
it('should render balance without symbol', () => {

View file

@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { jamSettingsStore } from '@/store/jamSettingsStore'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import { useJamDisplayContext } from './JamDisplayContext'
import { JamDisplayContextProvider } from './JamDisplayContextProvider'
@ -24,16 +25,18 @@ describe('JamDisplayContextProvider', () => {
})
it('should format amounts with the current display settings', () => {
render(
<JamDisplayContextProvider>
<DisplayProbe />
</JamDisplayContextProvider>,
)
withRuntimeLocale('en-US', () => {
render(
<JamDisplayContextProvider>
<DisplayProbe />
</JamDisplayContextProvider>,
)
expect(screen.getByTestId('default-amount')).toHaveTextContent('123,456,789')
expect(screen.getByTestId('btc-amount')).toHaveTextContent('1.23456789')
expect(screen.getByTestId('hidden-amount')).toHaveTextContent('*****')
expect(screen.getByTestId('sats-symbol')).toBeInTheDocument()
expect(screen.getByTestId('default-amount')).toHaveTextContent('123,456,789')
expect(screen.getByTestId('btc-amount')).toHaveTextContent('1.23456789')
expect(screen.getByTestId('hidden-amount')).toHaveTextContent('*****')
expect(screen.getByTestId('sats-symbol')).toBeInTheDocument()
})
})
it('should hide default amounts when privacy mode is enabled', () => {

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { JM_WALLET_FILE_EXTENSION } from '@/constants/jm'
import { withRuntimeLocale } from '@/test/withRuntimeLocale'
import {
cn,
debounce,
@ -37,24 +38,6 @@ import {
} from './utils'
import type { WalletFileName } from './utils'
const withRuntimeLocale = (locale: string, callback: () => void) => {
/* eslint-disable unicorn/no-this-outside-of-class -- mocking Number.prototype.toLocaleString requires `this` */
const numberToLocaleStringMock = vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (
this: number,
locales,
options,
) {
return Intl.NumberFormat(locales ?? locale, options).format(this)
})
/* eslint-enable unicorn/no-this-outside-of-class */
try {
callback()
} finally {
numberToLocaleStringMock.mockRestore()
}
}
describe('cn', () => {
it('should merge class names correctly', () => {
expect(cn('text-red-500', 'bg-blue-500')).toBe('text-red-500 bg-blue-500')
@ -502,8 +485,10 @@ describe('formatBtc', () => {
})
it('should handle large BTC values', () => {
expect(formatBtc(21000000)).toBe('21,000,000.00000000')
expect(formatBtc(100.99999999)).toBe('100.99999999')
withRuntimeLocale('en-US', () => {
expect(formatBtc(21000000)).toBe('21,000,000.00000000')
expect(formatBtc(100.99999999)).toBe('100.99999999')
})
})
it('should handle very small BTC values', () => {
@ -555,9 +540,11 @@ describe('getBtcParts', () => {
describe('formatSats', () => {
it('should format satoshi values with locale-specific thousands separators', () => {
expect(formatSats(1000)).toBe('1,000')
expect(formatSats(1000000)).toBe('1,000,000')
expect(formatSats(100000000)).toBe('100,000,000') // 1 BTC in sats
withRuntimeLocale('en-US', () => {
expect(formatSats(1000)).toBe('1,000')
expect(formatSats(1000000)).toBe('1,000,000')
expect(formatSats(100000000)).toBe('100,000,000') // 1 BTC in sats
})
})
it('should handle small satoshi values', () => {
@ -568,13 +555,17 @@ describe('formatSats', () => {
})
it('should handle large satoshi values', () => {
expect(formatSats(2100000000000000)).toBe('2,100,000,000,000,000') // 21M BTC in sats
expect(formatSats(12345678901)).toBe('12,345,678,901')
withRuntimeLocale('en-US', () => {
expect(formatSats(2100000000000000)).toBe('2,100,000,000,000,000') // 21M BTC in sats
expect(formatSats(12345678901)).toBe('12,345,678,901')
})
})
it('should handle negative satoshi values', () => {
expect(formatSats(-1000)).toBe('-1,000')
expect(formatSats(-1234567)).toBe('-1,234,567')
withRuntimeLocale('en-US', () => {
expect(formatSats(-1000)).toBe('-1,000')
expect(formatSats(-1234567)).toBe('-1,234,567')
})
})
it('should follow runtime locale when no explicit locale is provided', () => {

View file

@ -0,0 +1,52 @@
import { vi } from 'vitest'
const OriginalIntlNumberFormat = Intl.NumberFormat
/**
* Runs `callback` with `Number.prototype.toLocaleString` and `Intl.NumberFormat`
* mocked so that calls without an explicit locale argument format using
* `locale` instead of the host machine's default locale. Calls that already
* pass an explicit locale are left untouched. This keeps locale-dependent
* assertions deterministic across contributors' machines and CI without
* pinning a locale in app code.
*/
export function withRuntimeLocale(locale: string, callback: () => Promise<void>): Promise<void>
export function withRuntimeLocale(locale: string, callback: () => void): void
export function withRuntimeLocale(locale: string, callback: () => void | Promise<void>): void | Promise<void> {
/* eslint-disable unicorn/no-this-outside-of-class -- mocking Number.prototype.toLocaleString requires `this` */
const numberToLocaleStringMock = vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (
this: number,
locales,
options,
) {
return Intl.NumberFormat(locales ?? locale, options).format(this)
})
/* eslint-enable unicorn/no-this-outside-of-class */
const intlNumberFormatMock = vi.spyOn(Intl, 'NumberFormat').mockImplementation(function (
locales?: string | string[],
options?: Intl.NumberFormatOptions,
) {
return new OriginalIntlNumberFormat(locales ?? locale, options)
} as unknown as typeof Intl.NumberFormat)
const restore = () => {
numberToLocaleStringMock.mockRestore()
intlNumberFormatMock.mockRestore()
}
let result: void | Promise<void>
try {
result = callback()
} catch (error) {
restore()
throw error
}
if (result instanceof Promise) {
return result.finally(restore)
}
restore()
return undefined
}