diff --git a/e2e/__baselines__/buy-amount.png b/e2e/__baselines__/buy-amount.png new file mode 100644 index 00000000..101a3cb0 Binary files /dev/null and b/e2e/__baselines__/buy-amount.png differ diff --git a/e2e/__baselines__/sell-no-balance.png b/e2e/__baselines__/sell-no-balance.png new file mode 100644 index 00000000..ef9c7f62 Binary files /dev/null and b/e2e/__baselines__/sell-no-balance.png differ diff --git a/e2e/visual-coverage.json b/e2e/visual-coverage.json index f8c3219f..b46ed4ce 100644 --- a/e2e/visual-coverage.json +++ b/e2e/visual-coverage.json @@ -134,14 +134,12 @@ "reason": "Asset-management screen; needs onboarded state plus the asset-list fixture." }, "(auth)/buy/index.tsx": { - "status": "pending", - "tracking": "DFXswiss/dfx-wallet#186", - "reason": "DFX buy flow; needs onboarded state and a mocked DFX backend response before a deterministic baseline." + "status": "baselined", + "baselines": ["buy-amount"] }, "(auth)/sell/index.tsx": { - "status": "pending", - "tracking": "DFXswiss/dfx-wallet#186", - "reason": "DFX sell flow; needs onboarded state and a mocked DFX backend response before a deterministic baseline." + "status": "baselined", + "baselines": ["sell-no-balance"] }, "(auth)/kyc/index.tsx": { "status": "pending", diff --git a/e2e/visual/authenticated.test.ts b/e2e/visual/authenticated.test.ts index e2defe90..81686891 100644 --- a/e2e/visual/authenticated.test.ts +++ b/e2e/visual/authenticated.test.ts @@ -188,5 +188,41 @@ describe('Visual Regression (full variant)', () => { await pause(); await expectScreenToMatchBaseline('hardware-connect'); }); + + // Buy and sell are backend-driven, but their amount entry states are + // still deterministic once the route is open. Exercise both routes so a + // visual-only refactor cannot land without a real-device smoke test. + it('shows the buy amount screen', () => + device + .openURL({ url: 'dfxwallet://buy' }) + .then(() => + waitFor(element(by.id('buy-screen'))) + .toBeVisible() + .withTimeout(60_000), + ) + .then(() => element(by.text('BTC')).tap()) + .then(() => + waitFor(element(by.id('buy-amount-input'))) + .toBeVisible() + .withTimeout(30_000), + ) + .then(() => pause()) + .then(() => expectScreenToMatchBaseline('buy-amount'))); + + it('shows the sell amount entry state', () => + device + .openURL({ url: 'dfxwallet://sell' }) + .then(() => + waitFor(element(by.id('sell-screen'))) + .toBeVisible() + .withTimeout(60_000), + ) + .then(() => + waitFor(element(by.id('sell-no-balance'))) + .toBeVisible() + .withTimeout(60_000), + ) + .then(() => pause()) + .then(() => expectScreenToMatchBaseline('sell-no-balance'))); }); }); diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 9f859019..039a8837 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -14,6 +14,7 @@ type IconName = | 'close' | 'lightning' | 'arrow-left' + | 'arrow-right' | 'user' | 'shield' | 'globe' @@ -270,6 +271,18 @@ export function Icon({ name, size = 24, color, strokeWidth = 2 }: Props) { /> ); + case 'arrow-right': + return ( + + + + ); case 'user': return ( diff --git a/src/components/PrimaryButton.tsx b/src/components/PrimaryButton.tsx index 391dc70f..43c64574 100644 --- a/src/components/PrimaryButton.tsx +++ b/src/components/PrimaryButton.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet, Text } from 'react-native'; +import { useMemo, type ReactNode } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; import { Typography, useColors, type ThemeColors } from '@/theme'; type Props = { @@ -9,6 +9,7 @@ type Props = { loading?: boolean; variant?: 'filled' | 'outlined'; testID?: string; + icon?: ReactNode; }; export function PrimaryButton({ @@ -18,6 +19,7 @@ export function PrimaryButton({ loading, variant = 'filled', testID, + icon, }: Props) { const isFilled = variant === 'filled'; const colors = useColors(); @@ -39,7 +41,10 @@ export function PrimaryButton({ {loading ? ( ) : ( - {title} + + {title} + {icon} + )} ); @@ -54,6 +59,11 @@ const makeStyles = (colors: ThemeColors) => justifyContent: 'center', paddingHorizontal: 24, }, + content: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, filled: { backgroundColor: colors.primary, shadowColor: colors.primaryDark, diff --git a/src/features/buy-sell/BuyScreenImpl.tsx b/src/features/buy-sell/BuyScreenImpl.tsx index b6811aa2..25c73e13 100644 --- a/src/features/buy-sell/BuyScreenImpl.tsx +++ b/src/features/buy-sell/BuyScreenImpl.tsx @@ -27,6 +27,7 @@ import type { ChainId } from '@/config/chains'; import { formatFiat as fmtFiat, formatCryptoAmount as fmtCrypto, + SYMBOL_GLYPH, } from '@/config/portfolio-presentation'; import { useLdsWallet } from '@/hooks'; import { useLinkedWalletReauth } from '@/features/linked-wallets/useLinkedWalletReauth'; @@ -261,6 +262,7 @@ export default function BuyScreen() { const [amount, setAmount] = useState(''); const [selectedCurrency, setSelectedCurrency] = useState<(typeof CURRENCIES)[number]>('CHF'); const [copiedField, setCopiedField] = useState(null); + const [collapsed, setCollapsed] = useState(true); // After the user goes through the DFX login flow we land back on this // screen; replay the failed call so they don't have to retap "Continue". @@ -450,6 +452,21 @@ export default function BuyScreen() { const numAmount = parseFloat(amount); const belowMin = minVolume != null && numAmount > 0 && numAmount < minVolume; const aboveMax = maxVolume != null && numAmount > maxVolume; + const quoteHeader = unsupportedChain + ? t('buy.chainUnsupported') + : quoteError + ? t([`buy.quoteError.${quoteError}`, 'buy.quoteError.generic'], { code: quoteError }) + : needsContinue + ? t('buy.continueHint') + : isLoading + ? t('buy.fetchingQuote') + : hasQuote && paymentInfo + ? t('buy.rateInclFees', { + asset: targetAsset, + amount: fmtFiat(paymentInfo.exchangeRate), + currency: selectedCurrency, + }) + : t('buy.summary'); const renderAmountStep = () => ( @@ -542,7 +559,22 @@ export default function BuyScreen() { ) : null} + + {['50', '100', '250', '500'].map((val) => ( + setAmount(val)} + > + + {`${SYMBOL_GLYPH.get(selectedCurrency) ?? selectedCurrency}${val}`} + + + ))} + ))} - - {['100', '500', '1000', '5000'].map((val) => ( - setAmount(val)}> - {val} - - ))} - {showQuoteCard ? ( - - {t('buy.summary')} + [styles.quoteToggle, pressed && styles.pressed]} + onPress={() => setCollapsed((value) => !value)} + accessibilityRole="button" + > + + + {quoteHeader} + + {hasQuote && fees ? ( + + {fmtFiat(fees.total)} + + ) : null} {isLoading && !unsupportedChain ? ( ) : null} - - {unsupportedChain ? ( + + + + + {collapsed ? null : unsupportedChain ? ( {t('buy.chainUnsupported')} ) : hasQuote && fees ? ( <> @@ -613,6 +653,12 @@ export default function BuyScreen() { value={`${fmtFiat(fees.fixed)} ${selectedCurrency}`} /> ) : null} + {fees.bank > 0 ? ( + + ) : null} ) : quoteError ? ( @@ -665,10 +712,21 @@ export default function BuyScreen() { {error ? {error} : null} + + + + + + {t('buy.paymentMethodSepa')} + {t('buy.paymentMethodSepaHint')} + + + } onPress={async () => { if (!selectedChainSpec) return; if (hasTargetWallet) { @@ -904,11 +962,13 @@ function QuoteRow({ value, sub, emphasis, + accent, }: { label: string; value: string; sub?: string; emphasis?: boolean; + accent?: boolean; }) { const colors = useColors(); const styles = useMemo(() => makeStyles(colors), [colors]); @@ -916,7 +976,15 @@ function QuoteRow({ {label} - {value} + + {value} + {sub ? {sub} : null} @@ -1151,11 +1219,6 @@ const makeStyles = (colors: ThemeColors) => padding: 18, gap: 14, }, - quoteHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, quoteTitle: { ...Typography.bodySmall, fontWeight: '600', @@ -1197,6 +1260,66 @@ const makeStyles = (colors: ThemeColors) => quoteValueEmphasis: { fontWeight: '700', }, + quoteValueAccent: { + color: colors.primary, + }, + quoteToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + quoteToggleText: { + ...Typography.bodyMedium, + color: colors.text, + fontWeight: '500', + flex: 1, + }, + quoteFeeBadge: { + backgroundColor: colors.primaryLight, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 999, + }, + quoteFeeBadgeText: { + ...Typography.bodySmall, + color: colors.primary, + fontWeight: '600', + }, + quoteChevronOpen: { + transform: [{ rotate: '90deg' }], + }, + paymentMethodRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + backgroundColor: colors.cardOverlay, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border, + paddingVertical: 14, + paddingHorizontal: 16, + }, + paymentMethodIcon: { + width: 34, + height: 34, + borderRadius: 17, + backgroundColor: colors.primaryLight, + alignItems: 'center', + justifyContent: 'center', + }, + paymentMethodBody: { + flex: 1, + gap: 2, + }, + paymentMethodTitle: { + ...Typography.bodyMedium, + fontWeight: '600', + color: colors.text, + }, + paymentMethodHint: { + ...Typography.bodySmall, + color: colors.textSecondary, + }, quoteSub: { ...Typography.bodySmall, color: colors.textTertiary, diff --git a/src/features/buy-sell/SellScreenImpl.tsx b/src/features/buy-sell/SellScreenImpl.tsx index 73936c8c..39ccc9b3 100644 --- a/src/features/buy-sell/SellScreenImpl.tsx +++ b/src/features/buy-sell/SellScreenImpl.tsx @@ -241,6 +241,7 @@ export default function SellScreen() { const [payoutCurrency, setPayoutCurrency] = useState<(typeof FIAT_CURRENCIES)[number]>('CHF'); const [iban, setIban] = useState(''); const [copiedField, setCopiedField] = useState(null); + const [collapsed, setCollapsed] = useState(true); // Replay the last failed call after the user finishes the DFX login flow. const isDfxAuthenticated = useAuthStore((s) => s.isDfxAuthenticated); @@ -428,6 +429,19 @@ export default function SellScreen() { const numAmount = parseFloat(amount); const belowMin = minVolume != null && numAmount > 0 && numAmount < minVolume; const aboveMax = maxVolume != null && numAmount > maxVolume; + const quoteHeader = quoteError + ? t([`sell.quoteError.${quoteError}`, 'sell.quoteError.generic'], { code: quoteError }) + : needsContinue + ? t('sell.continueHint') + : isLoading + ? t('sell.fetchingQuote') + : hasQuote && paymentInfo + ? t('sell.rateInclFees', { + asset: sellAsset, + amount: fmtFiat(paymentInfo.exchangeRate), + currency: payoutCurrency, + }) + : t('sell.summary'); const renderAmountStep = () => ( @@ -473,7 +487,9 @@ export default function SellScreen() { {selectedAsset && availableChains.length === 0 ? ( - {t('sell.noBalance')} + + {t('sell.noBalance')} + ) : null} {selectedAsset && availableChains.length > 0 ? ( @@ -523,6 +539,7 @@ export default function SellScreen() { - - {t('sell.summary')} + [styles.quoteToggle, pressed && styles.pressed]} + onPress={() => setCollapsed((value) => !value)} + accessibilityRole="button" + > + + + {quoteHeader} + + {hasQuote && fees ? ( + + {fmtFiat(fees.total)} + + ) : null} {isLoading ? : null} - - {hasQuote && fees ? ( + + + + + {collapsed ? null : hasQuote && fees ? ( <> ) : null} + {fees.bank > 0 ? ( + + ) : null} ) : quoteError ? ( @@ -626,7 +665,8 @@ export default function SellScreen() { {error ? {error} : null} } onPress={() => { if (hasTargetWallet) { setConfirmError(null); @@ -839,11 +879,13 @@ function QuoteRow({ value, sub, emphasis, + accent, }: { label: string; value: string; sub?: string; emphasis?: boolean; + accent?: boolean; }) { const colors = useColors(); const styles = useMemo(() => makeStyles(colors), [colors]); @@ -851,7 +893,15 @@ function QuoteRow({ {label} - {value} + + {value} + {sub ? {sub} : null} @@ -1061,11 +1111,6 @@ const makeStyles = (colors: ThemeColors) => padding: 18, gap: 14, }, - quoteHeader: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, quoteTitle: { ...Typography.bodySmall, fontWeight: '600', @@ -1105,6 +1150,32 @@ const makeStyles = (colors: ThemeColors) => textAlign: 'right', }, quoteValueEmphasis: { fontWeight: '700' }, + quoteValueAccent: { color: colors.primary }, + quoteToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + quoteToggleText: { + ...Typography.bodyMedium, + color: colors.text, + fontWeight: '500', + flex: 1, + }, + quoteFeeBadge: { + backgroundColor: colors.primaryLight, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 999, + }, + quoteFeeBadgeText: { + ...Typography.bodySmall, + color: colors.primary, + fontWeight: '600', + }, + quoteChevronOpen: { + transform: [{ rotate: '90deg' }], + }, quoteSub: { ...Typography.bodySmall, color: colors.textTertiary, diff --git a/src/features/dfx-backend/services/dto/payment.ts b/src/features/dfx-backend/services/dto/payment.ts index 716c7866..b1af35a9 100644 --- a/src/features/dfx-backend/services/dto/payment.ts +++ b/src/features/dfx-backend/services/dto/payment.ts @@ -11,6 +11,11 @@ export type FeeDto = { network: number; min: number; dfx: number; + platform: number; + bank: number; + bankFixed?: number; + bankVariable?: number; + networkStart?: number; total: number; }; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index e0752679..35013da5 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -11,6 +11,7 @@ "confirmTransfer": "Ich habe die Überweisung getätigt", "continueHint": "Tippe auf Weiter, um deine Wallet mit DFX zu verknüpfen — dein Angebot erscheint danach automatisch.", "exchangeRate": "Wechselkurs", + "feeBank": "Bankgebühr", "feeDfx": "DFX-Gebühr", "feeFixed": "Fixe Gebühr", "feeNetwork": "Netzwerkgebühr", @@ -36,6 +37,9 @@ "RecommendationRequired": "Empfehlungscode erforderlich.", "generic": "Angebot konnte nicht erstellt werden ({{code}})." }, + "paymentMethodSepa": "SEPA-Banküberweisung", + "paymentMethodSepaHint": "Kostenlos · 0-1 Werktag", + "rateInclFees": "1 {{asset}} ≈ {{amount}} {{currency}} (inkl. Gebühren)", "recipient": "Empfänger", "reference": "Verwendungszweck", "selectAsset": "Was möchtest du kaufen?", @@ -535,6 +539,7 @@ "depositAddress": "Einzahlungsadresse", "depositTo": "Einzahlung an", "exchangeRate": "Wechselkurs", + "feeBank": "Bankgebühr", "feeDfx": "DFX-Gebühr", "feeFixed": "Fixe Gebühr", "feeNetwork": "Netzwerkgebühr", @@ -560,6 +565,7 @@ "RecommendationRequired": "Empfehlungscode erforderlich.", "generic": "Angebot konnte nicht erstellt werden ({{code}})." }, + "rateInclFees": "1 {{asset}} ≈ {{amount}} {{currency}} (inkl. Gebühren)", "selectAsset": "Was möchtest du verkaufen?", "summary": "Angebot", "title": "Verkaufen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 096e4f52..2c4a96b7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -11,6 +11,7 @@ "confirmTransfer": "I've sent the transfer", "continueHint": "Tap Weiter to link your wallet with DFX — your quote appears automatically once linking is done.", "exchangeRate": "Exchange rate", + "feeBank": "Bank fee", "feeDfx": "DFX fee", "feeFixed": "Fixed fee", "feeNetwork": "Network fee", @@ -36,6 +37,9 @@ "RecommendationRequired": "Referral code required.", "generic": "Quote could not be generated ({{code}})." }, + "paymentMethodSepa": "SEPA bank transfer", + "paymentMethodSepaHint": "Free · 0-1 business day", + "rateInclFees": "1 {{asset}} ≈ {{amount}} {{currency}} (incl. fees)", "recipient": "Recipient", "reference": "Reference code", "selectAsset": "What do you want to buy?", @@ -535,6 +539,7 @@ "depositAddress": "Deposit address", "depositTo": "Deposit to", "exchangeRate": "Exchange rate", + "feeBank": "Bank fee", "feeDfx": "DFX fee", "feeFixed": "Fixed fee", "feeNetwork": "Network fee", @@ -560,6 +565,7 @@ "RecommendationRequired": "Referral code required.", "generic": "Quote could not be generated ({{code}})." }, + "rateInclFees": "1 {{asset}} ≈ {{amount}} {{currency}} (incl. fees)", "selectAsset": "What do you want to sell?", "summary": "Quote", "title": "Sell", diff --git a/test/components/BuyScreenImpl.test.tsx b/test/components/BuyScreenImpl.test.tsx index 49aa611a..4fedc43b 100644 --- a/test/components/BuyScreenImpl.test.tsx +++ b/test/components/BuyScreenImpl.test.tsx @@ -1,5 +1,7 @@ import React from 'react'; import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import BuyScreenImpl from '../../src/features/buy-sell/BuyScreenImpl'; +import SellScreenImpl from '../../src/features/buy-sell/SellScreenImpl'; jest.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -34,6 +36,19 @@ jest.mock('@tetherto/wdk-react-native-core', () => ({ address: 'bc1q-wallet-address', sign: jest.fn().mockResolvedValue({ success: true, signature: 'signed-message' }), }), + useBalancesForWallet: () => ({ + data: [{ assetId: 'btc', success: true, balance: '1' }], + }), +})); + +jest.mock('@/config/tokens', () => ({ + getAssets: () => [{ getNetwork: () => 'bitcoin', getId: () => 'btc', getDecimals: () => 8 }], + getAssetMeta: () => ({ symbol: 'BTC' }), + WDK_SUPPORTED_CHAINS: ['bitcoin'], +})); + +jest.mock('@/features/portfolio/useEnabledChains', () => ({ + useEnabledChains: () => ({ enabledChains: ['bitcoin'] }), })); jest.mock('@/hooks', () => ({ @@ -132,6 +147,11 @@ const mockCreatePaymentInfo = jest.fn(); const mockConfirmPayment = jest.fn(); const mockDismissAuthGate = jest.fn(); const mockRetryLast = jest.fn(); +const mockSellGetQuote = jest.fn(); +const mockSellCreatePaymentInfo = jest.fn(); +const mockSellConfirmPayment = jest.fn(); +const mockSellDismissAuthGate = jest.fn(); +const mockSellRetryLast = jest.fn(); const flowState = { isLoading: false, @@ -154,8 +174,19 @@ jest.mock('../../src/features/buy-sell/useBuyFlow', () => ({ }), })); -// eslint-disable-next-line import/first -import BuyScreenImpl from '../../src/features/buy-sell/BuyScreenImpl'; +jest.mock('../../src/features/buy-sell/useSellFlow', () => ({ + useSellFlow: () => ({ + paymentInfo: mockSellFlowState.paymentInfo, + isLoading: mockSellFlowState.isLoading, + error: mockSellFlowState.error, + authGate: mockSellFlowState.authGate, + getQuote: mockSellGetQuote, + createPaymentInfo: mockSellCreatePaymentInfo, + confirmSell: mockSellConfirmPayment, + dismissAuthGate: mockSellDismissAuthGate, + retryLast: mockSellRetryLast, + }), +})); const PAYMENT_INFO = { id: 321, @@ -171,15 +202,36 @@ const PAYMENT_INFO = { maxVolume: 10000, currency: { name: 'CHF' }, asset: { name: 'BTC' }, + rate: 101000, fees: { rate: 0.01, dfx: 1, network: 0, fixed: 0, + bank: 0, + platform: 0, + min: 0, total: 1, }, }; +const SELL_PAYMENT_INFO = { + ...PAYMENT_INFO, + amount: 0.001, + estimatedAmount: 100, + exchangeRate: 100000, + asset: { name: 'BTC' }, + currency: { name: 'CHF' }, + beneficiary: { iban: 'CH9300762011623852957' }, +}; + +const mockSellFlowState = { + isLoading: false, + error: null as string | null, + authGate: null, + paymentInfo: SELL_PAYMENT_INFO as Record | null, +}; + beforeEach(() => { mockBack.mockReset(); mockGetQuote.mockReset(); @@ -191,6 +243,15 @@ beforeEach(() => { flowState.error = null; flowState.authGate = null; flowState.paymentInfo = PAYMENT_INFO; + mockSellFlowState.isLoading = false; + mockSellFlowState.error = null; + mockSellFlowState.authGate = null; + mockSellFlowState.paymentInfo = SELL_PAYMENT_INFO; + mockSellGetQuote.mockReset(); + mockSellCreatePaymentInfo.mockReset(); + mockSellConfirmPayment.mockReset(); + mockSellDismissAuthGate.mockReset(); + mockSellRetryLast.mockReset(); }); describe('BuyScreenImpl', () => { @@ -203,7 +264,7 @@ describe('BuyScreenImpl', () => { fireEvent.press(getByText('BTC')); fireEvent.changeText(getByPlaceholderText('0.00'), '100'); await act(async () => { - fireEvent.press(getByText('common.continue')); + fireEvent.press(getByText('buy.title BTC')); }); await waitFor(() => expect(getByText('buy.paymentInfo')).toBeTruthy()); @@ -216,4 +277,50 @@ describe('BuyScreenImpl', () => { expect(queryByText('buy.confirmDescription')).toBeNull(); expect(getByText('buy.paymentInfo')).toBeTruthy(); }); + + it('surfaces a rejected quote while the quote card is collapsed', () => { + flowState.paymentInfo = { ...PAYMENT_INFO, isValid: false, error: 'KycRequired' }; + + const { getByPlaceholderText, getByText } = render(); + + fireEvent.press(getByText('BTC')); + fireEvent.changeText(getByPlaceholderText('0.00'), '100'); + + expect(getByText(/buy\.quoteError\.KycRequired/).props.children).toContain( + 'buy.quoteError.KycRequired', + ); + }); + + it('renders the static payment method as information, not an action', () => { + const { getByPlaceholderText, getByText, getByTestId } = render(); + + fireEvent.press(getByText('BTC')); + fireEvent.changeText(getByPlaceholderText('0.00'), '100'); + + expect(getByTestId('buy-payment-method-row').props.accessibilityRole).toBeUndefined(); + }); +}); + +describe('SellScreenImpl', () => { + it('shows the sell amount panel and collapsed quote summary for a held asset', () => { + const { getByPlaceholderText, getByText } = render(); + + fireEvent.press(getByText('BTC')); + fireEvent.changeText(getByPlaceholderText('0.00'), '0.001'); + + expect(getByText(/sell\.rateInclFees/).props.children).toContain('sell.rateInclFees'); + }); + + it('surfaces a rejected sell quote while the quote card is collapsed', () => { + mockSellFlowState.paymentInfo = { ...SELL_PAYMENT_INFO, isValid: false, error: 'KycRequired' }; + + const { getByPlaceholderText, getByText } = render(); + + fireEvent.press(getByText('BTC')); + fireEvent.changeText(getByPlaceholderText('0.00'), '0.001'); + + expect(getByText(/sell\.quoteError\.KycRequired/).props.children).toContain( + 'sell.quoteError.KycRequired', + ); + }); }); diff --git a/test/components/Icon.test.tsx b/test/components/Icon.test.tsx index 46b516fc..37fa2685 100644 --- a/test/components/Icon.test.tsx +++ b/test/components/Icon.test.tsx @@ -18,6 +18,7 @@ const NAMES = [ 'close', 'lightning', 'arrow-left', + 'arrow-right', 'user', 'shield', 'globe', diff --git a/test/components/PrimaryButton.test.tsx b/test/components/PrimaryButton.test.tsx index 63f2c52c..38946dfb 100644 --- a/test/components/PrimaryButton.test.tsx +++ b/test/components/PrimaryButton.test.tsx @@ -1,4 +1,4 @@ -import { ActivityIndicator } from 'react-native'; +import { ActivityIndicator, Text } from 'react-native'; import { render, fireEvent } from '@testing-library/react-native'; import { PrimaryButton } from '../../src/components/PrimaryButton'; @@ -39,4 +39,26 @@ describe('PrimaryButton', () => { ); expect(getByText('Cancel')).toBeTruthy(); }); + + it('renders an optional icon next to the title', () => { + const { getByText } = render( + icon} onPress={() => {}} />, + ); + expect(getByText('Continue').props.children).toBe('Continue'); + expect(getByText('icon').props.children).toBe('icon'); + }); + + it('renders no icon when the icon prop is omitted (existing callers stay unaffected)', () => { + const { queryByText } = render( {}} />); + expect(queryByText('icon')).toBeNull(); + }); + + it('hides the icon while loading, showing only the spinner', () => { + const { queryByText, UNSAFE_getByType } = render( + icon} onPress={() => {}} loading />, + ); + expect(queryByText('Continue')).toBeNull(); + expect(queryByText('icon')).toBeNull(); + expect(UNSAFE_getByType(ActivityIndicator).type).toBe(ActivityIndicator); + }); });