diff --git a/src/components/import/ImportDetailsForm.test.tsx b/src/components/import/ImportDetailsForm.test.tsx index 3447000e..1dd18e8b 100644 --- a/src/components/import/ImportDetailsForm.test.tsx +++ b/src/components/import/ImportDetailsForm.test.tsx @@ -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( - , - ) - 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( + , + ) + 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 () => { diff --git a/src/components/settings/RescanChainPage.test.tsx b/src/components/settings/RescanChainPage.test.tsx index 0958d730..1e4c52df 100644 --- a/src/components/settings/RescanChainPage.test.tsx +++ b/src/components/settings/RescanChainPage.test.tsx @@ -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 () => { diff --git a/src/components/ui/jam/Balance.test.tsx b/src/components/ui/jam/Balance.test.tsx index b5c8ce40..0915d358 100644 --- a/src/components/ui/jam/Balance.test.tsx +++ b/src/components/ui/jam/Balance.test.tsx @@ -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) => { const providers = ({ children }: { children: React.ReactNode }) => { @@ -35,11 +36,13 @@ describe('', () => { }) it('should render balance in SATS', () => { - render() - 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() + 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('', () => { }) it('should render a string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`12,303,224,961`) - expect(screen.getByTestId('sats-symbol')).toBeVisible() + withRuntimeLocale('en-US', () => { + render() + 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('', () => { }) it('should render a large string BTC value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + }) }) it('should render a large string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + }) }) it('should render a max string BTC value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + }) }) it('should render a max string BTC value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + withRuntimeLocale('en-US', () => { + render() + 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('', () => { }) it('should render a large string SATS value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`20,999,999.97690000`) + }) }) it('should render a large string SATS value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,099,999,997,690,000`) + }) }) it('should render a max string SATS value correctly as BTC', () => { - render() - expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId('bitcoin-amount').dataset.formattedValue).toBe(`21,000,000.00000000`) + }) }) it('should render a max string SATS value correctly as SATS', () => { - render() - expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + withRuntimeLocale('en-US', () => { + render() + expect(screen.getByTestId(`sats-amount`)).toHaveTextContent(`2,100,000,000,000,000`) + }) }) it('should render frozen balance in BTC', () => { @@ -132,10 +153,12 @@ describe('', () => { }) it('should render frozen balance in SATS', () => { - render() - 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() + 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', () => { diff --git a/src/context/JamDisplayContextProvider.test.tsx b/src/context/JamDisplayContextProvider.test.tsx index acba726c..e2c2d2a4 100644 --- a/src/context/JamDisplayContextProvider.test.tsx +++ b/src/context/JamDisplayContextProvider.test.tsx @@ -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( - - - , - ) + withRuntimeLocale('en-US', () => { + render( + + + , + ) - 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', () => { diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 63e14d6a..f63bcad5 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -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', () => { diff --git a/src/test/withRuntimeLocale.ts b/src/test/withRuntimeLocale.ts new file mode 100644 index 00000000..d8d41354 --- /dev/null +++ b/src/test/withRuntimeLocale.ts @@ -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): Promise +export function withRuntimeLocale(locale: string, callback: () => void): void +export function withRuntimeLocale(locale: string, callback: () => void | Promise): void | Promise { + /* 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 + try { + result = callback() + } catch (error) { + restore() + throw error + } + + if (result instanceof Promise) { + return result.finally(restore) + } + + restore() + return undefined +}