diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 728850b..24cb605 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -16,6 +16,7 @@ import PercentageBadge from '@/components/common/PercentageBadge'; import NetworkFeeHint from '@/components/common/NetworkFeeHint'; import { TRADE_FEE_ESTIMATE } from '@/constants/fees'; import { formatTransactionFeeDisplay } from '@/utils/transactionFee.utils'; +import { clampBuyQuantity } from '@/utils/buyQuantity'; export type TradeSide = 'buy' | 'sell'; @@ -52,6 +53,17 @@ const TradeDialog: React.FC = ({ } }, [open]); + const handleBlur = () => { + setTouched(true); + const normalized = amountText.trim(); + if (normalized) { + const clampedResult = clampBuyQuantity(amountText); + if (clampedResult.adjusted) { + setAmountText(clampedResult.value.toString()); + } + } + }; + const parsedAmount = useMemo(() => { const normalized = amountText.trim(); if (!normalized) return NaN; @@ -126,7 +138,7 @@ const TradeDialog: React.FC = ({ setAmountText(event.target.value); setTouched(true); }} - onBlur={() => setTouched(true)} + onBlur={handleBlur} disabled={isSubmitting} className={cn( 'w-full rounded-xl border bg-white/[0.04] px-3 py-2 text-white outline-none transition-colors', diff --git a/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx b/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx deleted file mode 100644 index 98f6e30..0000000 --- a/src/components/common/__tests__/TradeDialog.buyQuantity.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import TradeDialog from '@/components/common/TradeDialog'; -import { BUY_QUANTITY_BOUNDS } from '@/constants/fees'; - -describe('TradeDialog buy quantity clamping and notes', () => { - function renderDialog( - overrides: Partial> = {} - ) { - return render( - - ); - } - - it('clamps values below minimum to minimum on blur', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Input below minimum - fireEvent.change(input, { target: { value: '0' } }); - fireEvent.blur(input); - - expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); - expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( - `Quantity adjusted to the minimum of ${BUY_QUANTITY_BOUNDS.MIN_QTY}.` - ); - }); - - it('clamps values above maximum to maximum on blur', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Input above maximum - fireEvent.change(input, { target: { value: '150' } }); - fireEvent.blur(input); - - expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); - expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( - `Quantity adjusted to the maximum of ${BUY_QUANTITY_BOUNDS.MAX_QTY}.` - ); - }); - - it('rounds decimal inputs on blur', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Decimal input - fireEvent.change(input, { target: { value: '5.6' } }); - fireEvent.blur(input); - - expect(input.value).toBe('6'); - expect(screen.getByTestId('buy-qty-adjustment-note')).toHaveTextContent( - 'Quantity rounded to 6.' - ); - }); - - it('does not clamp or show a note for valid quantities on blur', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Valid quantity - fireEvent.change(input, { target: { value: '10' } }); - fireEvent.blur(input); - - expect(input.value).toBe('10'); - expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument(); - }); - - it('clears the adjustment note on input change', () => { - renderDialog(); - const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; - - // Trigger adjustment note - fireEvent.change(input, { target: { value: '0' } }); - fireEvent.blur(input); - expect(screen.getByTestId('buy-qty-adjustment-note')).toBeInTheDocument(); - - // Change input - fireEvent.change(input, { target: { value: '5' } }); - expect(screen.queryByTestId('buy-qty-adjustment-note')).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx b/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx new file mode 100644 index 0000000..5457850 --- /dev/null +++ b/src/components/common/__tests__/TradeDialog.clamp.integration.test.tsx @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TradeDialog from '@/components/common/TradeDialog'; +import { BUY_QUANTITY_BOUNDS } from '@/constants/fees'; + +describe('TradeDialog – clampBuyQuantity integration', () => { + function renderDialog( + overrides: Partial> = {} + ) { + return render( + + ); + } + + it('applies clampBuyQuantity to amount input field – values below minimum are clamped to MIN_QTY', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input value below minimum + fireEvent.change(input, { target: { value: '0' } }); + fireEvent.blur(input); + + // Verify the value is clamped to minimum + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); + }); + + it('applies clampBuyQuantity to amount input field – values above maximum are clamped to MAX_QTY', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input value above maximum + fireEvent.change(input, { target: { value: '150' } }); + fireEvent.blur(input); + + // Verify the value is clamped to maximum + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); + }); + + it('applies clampBuyQuantity to amount input field – decimal values are rounded to nearest integer', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input decimal value + fireEvent.change(input, { target: { value: '5.6' } }); + fireEvent.blur(input); + + // Verify the value is rounded + expect(input.value).toBe('6'); + }); + + it('applies clampBuyQuantity to amount input field – valid values within bounds remain unchanged', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input valid value within bounds + fireEvent.change(input, { target: { value: '10' } }); + fireEvent.blur(input); + + // Verify the value remains unchanged + expect(input.value).toBe('10'); + }); + + it('applies clampBuyQuantity to amount input field – negative values are clamped to MIN_QTY', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input negative value + fireEvent.change(input, { target: { value: '-5' } }); + fireEvent.blur(input); + + // Verify the value is clamped to minimum + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); + }); + + it('applies clampBuyQuantity to amount input field – empty input is handled gracefully', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Clear the input + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + + // Verify empty input shows validation error (not clamped, as it's invalid) + expect(screen.getByTestId('trade-dialog-amount-error')).toHaveTextContent( + 'Please enter an amount.' + ); + }); + + it('applies clampBuyQuantity to amount input field – boundary values (MIN_QTY and MAX_QTY) are accepted', () => { + renderDialog(); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Test minimum boundary + fireEvent.change(input, { target: { value: BUY_QUANTITY_BOUNDS.MIN_QTY.toString() } }); + fireEvent.blur(input); + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MIN_QTY.toString()); + + // Test maximum boundary + fireEvent.change(input, { target: { value: BUY_QUANTITY_BOUNDS.MAX_QTY.toString() } }); + fireEvent.blur(input); + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); + }); + + it('applies clampBuyQuantity to amount input field – sell side also respects clamping', () => { + renderDialog({ side: 'sell', availableHoldings: 50 }); + const input = screen.getByTestId('trade-dialog-amount') as HTMLInputElement; + + // Input value above maximum on sell side + fireEvent.change(input, { target: { value: '150' } }); + fireEvent.blur(input); + + // Verify the value is clamped to maximum + expect(input.value).toBe(BUY_QUANTITY_BOUNDS.MAX_QTY.toString()); + }); +});