import { View, Text, Platform, TextInput } from 'react-native'; import * as React from 'react'; import { cleanup, render, screen, fireEvent, act, } from '@testing-library/react-native'; import { OTPInput } from './input'; import type { InputOTPRenderFn, OTPInputProps, RenderProps, SlotProps, OTPInputRef, } from './types'; afterEach(cleanup); afterEach(() => { // Reset Platform.OS mock after each test Platform.OS = 'ios'; }); const onChangeMock: OTPInputProps['onChange'] = jest.fn(); const onCompleteMock: OTPInputProps['onComplete'] = jest.fn(); const defaultRender: InputOTPRenderFn = (props: RenderProps) => ( {props.slots.map((slot: SlotProps, index: number) => ( {slot.char && {slot.char}} ))} ); /** * Simulate typing on the input like a user would do by typing one character at a time. * * @param input - The input element to simulate typing on. * @param text - The text to simulate typing. */ const simulateTyping = (input: TextInput, text: string) => { let accumulated = ''; for (const char of text) { accumulated += char; fireEvent.changeText(input, accumulated); fireEvent(input, 'keyPress', { nativeEvent: { key: char } }); } }; // Mock Platform.OS jest.mock('react-native/Libraries/Utilities/Platform', () => ({ OS: 'ios', select: jest.fn(), })); describe('OTPInput', () => { beforeEach(() => { jest.clearAllMocks(); }); describe('Rendering', () => { test('renders correctly with default props', async () => { render( ); const input = await screen.findByTestId('otp-input'); expect(input).toBeTruthy(); expect(input.props.inputMode).toBe('numeric'); expect(input.props.autoComplete).toBe('one-time-code'); }); test('sets correct autoComplete value for Android', async () => { Platform.OS = 'android'; render( ); const input = await screen.findByTestId('otp-input'); expect(input.props.autoComplete).toBe('sms-otp'); }); test('sets correct autoComplete value for iOS', async () => { Platform.OS = 'ios'; render( ); const input = await screen.findByTestId('otp-input'); expect(input.props.autoComplete).toBe('one-time-code'); }); test('renders correctly without render prop', async () => { render(); const input = await screen.findByTestId('otp-input'); expect(input).toBeTruthy(); // The container should still be rendered const container = await screen.findByTestId('otp-input-container'); expect(container).toBeTruthy(); // Container should have exactly two children: // 1. null from the render prop (React still counts this) // 2. The TextInput component const customContent = container.children.length; expect(customContent).toBe(2); }); test('renders with custom props', async () => { const placeholder = '******'; render( ); const input = await screen.findByTestId('otp-input'); expect(input.props.placeholder).toBe(placeholder); expect(input.props.inputMode).toBe('text'); }); test('renders custom content using render prop', async () => { const customRender: InputOTPRenderFn = (props: RenderProps) => ( {props.slots.map((slot, index) => ( {slot.char || slot.placeholderChar} ))} ); render( ); expect(await screen.findByTestId('custom-content')).toBeTruthy(); }); }); describe('Interactions', () => { test('handles text input correctly', async () => { render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '123456'); expect(onChangeMock).toHaveBeenCalledWith('123456'); expect(onCompleteMock).toHaveBeenCalledWith('123456'); }); test('onComplete is called when maxLength is reached only', async () => { render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '12345'); expect(onChangeMock).toHaveBeenCalledWith('12345'); expect(onCompleteMock).not.toHaveBeenCalled(); simulateTyping(input, '123456'); expect(onChangeMock).toHaveBeenCalledWith('123456'); expect(onCompleteMock).toHaveBeenCalledWith('123456'); }); test('clears input on container press', async () => { render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '123'); const container = await screen.findByTestId('otp-input-container'); fireEvent.press(container); expect(input.props.value).toBe(''); }); test('does not clear input on container press when clearTextOnFocus is false', async () => { render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '123'); const container = await screen.findByTestId('otp-input-container'); fireEvent.press(container); expect(input.props.value).toBe('123'); }); test('cursor is always locked to the end of the value', async () => { render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '12'); // selection prop should always be at end expect(input.props.selection).toEqual({ start: 2, end: 2 }); }); test('cursor resets to end when onSelectionChange fires with wrong position', async () => { render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '12'); // Simulate cursor moved to position 0 by arrow key fireEvent(input, 'selectionChange', { nativeEvent: { selection: { start: 0, end: 0 } }, }); // After the state reset re-render, selection prop is back at end expect(input.props.selection).toEqual({ start: 2, end: 2 }); }); describe('slot.focus', () => { test('each slot has a focus function', async () => { let capturedSlots: SlotProps[] = []; const captureRender: InputOTPRenderFn = (props: RenderProps) => { capturedSlots = props.slots; return ; }; render( ); await screen.findByTestId('otp-input'); expect(capturedSlots).toHaveLength(4); capturedSlots.forEach((slot) => { expect(typeof slot.focus).toBe('function'); }); }); test('slot.focus() truncates value to the slot index', async () => { let capturedSlots: SlotProps[] = []; const captureRender: InputOTPRenderFn = (props: RenderProps) => { capturedSlots = props.slots; return ; }; render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '123456'); await act(async () => { capturedSlots[3]!.focus(); }); expect(input.props.value).toBe('123'); expect(onChangeMock).toHaveBeenCalledWith('123'); }); test('slot.focus() suppresses subsequent iOS clearTextOnFocus empty string', async () => { let capturedSlots: SlotProps[] = []; const captureRender: InputOTPRenderFn = (props: RenderProps) => { capturedSlots = props.slots; return ; }; render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '123456'); await act(async () => { capturedSlots[3]!.focus(); }); // Simulate iOS native clearTextOnFocus firing onChangeText('') fireEvent.changeText(input, ''); // Value should remain at '123', not cleared expect(input.props.value).toBe('123'); }); test('subsequent onChangeText calls work normally after suppression is consumed', async () => { let capturedSlots: SlotProps[] = []; const captureRender: InputOTPRenderFn = (props: RenderProps) => { capturedSlots = props.slots; return ; }; render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '123456'); await act(async () => { capturedSlots[3]!.focus(); }); // Consume the suppression with simulated iOS clear fireEvent.changeText(input, ''); expect(input.props.value).toBe('123'); // Subsequent typing should work normally fireEvent.changeText(input, '1234'); expect(input.props.value).toBe('1234'); expect(onChangeMock).toHaveBeenCalledWith('1234'); }); }); test('handles focus and blur events', async () => { render( ); const input = await screen.findByTestId('otp-input'); const cells = await screen.findByTestId('otp-cells'); fireEvent(input, 'focus'); expect(cells.props['data-focused']).toBe(true); fireEvent(input, 'blur'); expect(cells.props['data-focused']).toBe(false); }); }); describe('Validation', () => { test('respects pattern prop for input validation', async () => { render( ); const input = await screen.findByTestId('otp-input'); // Test invalid input simulateTyping(input, 'abc'); expect(onChangeMock).toHaveBeenCalledTimes(2); expect(onChangeMock).toHaveBeenCalledWith(''); expect(input.props.value).toBe(''); // Test valid input simulateTyping(input, '123'); expect(onChangeMock).toHaveBeenCalledWith('123'); expect(input.props.value).toBe('123'); }); test('respects maxLength prop', async () => { render( ); const input = await screen.findByTestId('otp-input'); simulateTyping(input, '123456'); expect(input.props.value.length).toBeLessThanOrEqual(4); }); }); describe('Ref Methods', () => { test('setValue updates input value through ref', async () => { const ref = React.createRef(); render( ); const input = await screen.findByTestId('otp-input'); await act(async () => { ref.current?.setValue('1'); }); expect(input.props.value).toBe('1'); expect(onChangeMock).toHaveBeenCalledWith('1'); }); test('focus method focuses the input through ref', async () => { const ref = React.createRef(); render( ); const cells = await screen.findByTestId('otp-cells'); const input = await screen.findByTestId('otp-input'); await act(async () => { ref.current?.focus(); // we need to call onFocus by fireEvent because test environment do not support onFocus // see the issue https://github.com/callstack/react-native-testing-library/issues/1069 fireEvent(input, 'focus'); }); expect(cells.props['data-focused']).toBe(true); }); test('blur method blurs the input through ref', async () => { const ref = React.createRef(); render( ); const input = await screen.findByTestId('otp-input'); const cells = await screen.findByTestId('otp-cells'); // First focus await act(async () => { ref.current?.focus(); // we need to call onFocus by fireEvent because test environment do not support onFocus // see the issue https://github.com/callstack/react-native-testing-library/issues/1069 fireEvent(input, 'focus'); }); expect(cells.props['data-focused']).toBe(true); // Then blur await act(async () => { ref.current?.blur(); // we need to call onBlur by fireEvent because test environment do not support onBlur // see the issue https://github.com/callstack/react-native-testing-library/issues/1069 fireEvent(input, 'blur'); }); expect(cells.props['data-focused']).toBe(false); }); test('clear method clears the input through ref', async () => { const ref = React.createRef(); render( ); const input = await screen.findByTestId('otp-input'); // First set a value await act(async () => { ref.current?.setValue('1'); }); expect(input.props.value).toBe('1'); // Then clear it await act(async () => { ref.current?.clear(); }); expect(input.props.value).toBe(''); }); }); });