mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
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:
parent
4c2a886c6b
commit
9fb32af42f
6 changed files with 165 additions and 90 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
52
src/test/withRuntimeLocale.ts
Normal file
52
src/test/withRuntimeLocale.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue