Skip to content

Commit 8af17e8

Browse files
committed
feat: add missed test to input
1 parent 0346e73 commit 8af17e8

File tree

5 files changed

+369
-6
lines changed

5 files changed

+369
-6
lines changed

src/input.test.tsx

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
import { View, Text, Platform } from 'react-native';
2+
import {
3+
cleanup,
4+
render,
5+
screen,
6+
fireEvent,
7+
} from '@testing-library/react-native';
8+
9+
import { OTPInput } from './input';
10+
import type {
11+
InputOTPRenderFn,
12+
OTPInputProps,
13+
RenderProps,
14+
SlotProps,
15+
} from './types';
16+
17+
afterEach(cleanup);
18+
afterEach(() => {
19+
// Reset Platform.OS mock after each test
20+
Platform.OS = 'ios';
21+
});
22+
23+
const onChangeMock: OTPInputProps['onChange'] = jest.fn();
24+
const onCompleteMock: OTPInputProps['onComplete'] = jest.fn();
25+
26+
const defaultRender: InputOTPRenderFn = (props: RenderProps) => (
27+
<View testID="otp-cells" data-focused={props.isFocused}>
28+
{props.slots.map((slot: SlotProps, index: number) => (
29+
<View
30+
key={index}
31+
testID={`otp-cell-${index}`}
32+
style={{ opacity: props.isFocused ? 1 : 0.5 }}
33+
>
34+
{slot.char && <Text>{slot.char}</Text>}
35+
</View>
36+
))}
37+
</View>
38+
);
39+
40+
// Mock Platform.OS
41+
jest.mock('react-native/Libraries/Utilities/Platform', () => ({
42+
OS: 'ios',
43+
select: jest.fn(),
44+
}));
45+
46+
describe('OTPInput', () => {
47+
beforeEach(() => {
48+
jest.clearAllMocks();
49+
});
50+
51+
describe('Rendering', () => {
52+
test('renders correctly with default props', async () => {
53+
render(
54+
<OTPInput
55+
onChange={onChangeMock}
56+
maxLength={6}
57+
render={defaultRender}
58+
/>
59+
);
60+
61+
const input = await screen.findByTestId('otp-input');
62+
expect(input).toBeTruthy();
63+
expect(input.props.maxLength).toBe(6);
64+
expect(input.props.inputMode).toBe('numeric');
65+
expect(input.props.autoComplete).toBe('one-time-code');
66+
});
67+
68+
test('sets correct autoComplete value for Android', async () => {
69+
Platform.OS = 'android';
70+
71+
render(
72+
<OTPInput
73+
onChange={onChangeMock}
74+
maxLength={6}
75+
render={defaultRender}
76+
/>
77+
);
78+
79+
const input = await screen.findByTestId('otp-input');
80+
expect(input.props.autoComplete).toBe('sms-otp');
81+
});
82+
83+
test('sets correct autoComplete value for iOS', async () => {
84+
Platform.OS = 'ios';
85+
86+
render(
87+
<OTPInput
88+
onChange={onChangeMock}
89+
maxLength={6}
90+
render={defaultRender}
91+
/>
92+
);
93+
94+
const input = await screen.findByTestId('otp-input');
95+
expect(input.props.autoComplete).toBe('one-time-code');
96+
});
97+
98+
test('renders correctly without render prop', async () => {
99+
render(<OTPInput onChange={onChangeMock} maxLength={6} />);
100+
101+
const input = await screen.findByTestId('otp-input');
102+
expect(input).toBeTruthy();
103+
104+
// The container should still be rendered
105+
const container = await screen.findByTestId('otp-input-container');
106+
expect(container).toBeTruthy();
107+
108+
// Container should have exactly two children:
109+
// 1. null from the render prop (React still counts this)
110+
// 2. The TextInput component
111+
const customContent = container.children.length;
112+
expect(customContent).toBe(2);
113+
});
114+
115+
test('renders with custom props', async () => {
116+
const placeholder = '******';
117+
render(
118+
<OTPInput
119+
onChange={onChangeMock}
120+
maxLength={4}
121+
placeholder={placeholder}
122+
inputMode="text"
123+
render={defaultRender}
124+
/>
125+
);
126+
127+
const input = await screen.findByTestId('otp-input');
128+
expect(input.props.maxLength).toBe(4);
129+
expect(input.props.placeholder).toBe(placeholder);
130+
expect(input.props.inputMode).toBe('text');
131+
});
132+
133+
test('renders custom content using render prop', async () => {
134+
const customRender: InputOTPRenderFn = (props: RenderProps) => (
135+
<View testID="custom-content">
136+
{props.slots.map((slot, index) => (
137+
<Text key={index}>{slot.char || slot.placeholderChar}</Text>
138+
))}
139+
</View>
140+
);
141+
142+
render(
143+
<OTPInput onChange={onChangeMock} maxLength={4} render={customRender} />
144+
);
145+
146+
expect(await screen.findByTestId('custom-content')).toBeTruthy();
147+
});
148+
});
149+
150+
describe('Interactions', () => {
151+
test('handles text input correctly', async () => {
152+
render(
153+
<OTPInput
154+
onChange={onChangeMock}
155+
maxLength={6}
156+
onComplete={onCompleteMock}
157+
render={defaultRender}
158+
/>
159+
);
160+
161+
const input = await screen.findByTestId('otp-input');
162+
fireEvent.changeText(input, '123456');
163+
164+
expect(onChangeMock).toHaveBeenCalledWith('123456');
165+
expect(onCompleteMock).toHaveBeenCalledWith('123456');
166+
});
167+
168+
test('onComplete is called when maxLength is reached only', async () => {
169+
render(
170+
<OTPInput
171+
onChange={onChangeMock}
172+
maxLength={6}
173+
onComplete={onCompleteMock}
174+
render={defaultRender}
175+
/>
176+
);
177+
178+
const input = await screen.findByTestId('otp-input');
179+
fireEvent.changeText(input, '12345');
180+
expect(onChangeMock).toHaveBeenCalledWith('12345');
181+
expect(onCompleteMock).not.toHaveBeenCalled();
182+
183+
fireEvent.changeText(input, '123456');
184+
expect(onChangeMock).toHaveBeenCalledWith('123456');
185+
expect(onCompleteMock).toHaveBeenCalledWith('123456');
186+
});
187+
188+
test('clears input on container press', async () => {
189+
render(
190+
<OTPInput
191+
onChange={onChangeMock}
192+
maxLength={6}
193+
render={defaultRender}
194+
/>
195+
);
196+
197+
const input = await screen.findByTestId('otp-input');
198+
fireEvent.changeText(input, '123');
199+
200+
const container = await screen.findByTestId('otp-input-container');
201+
fireEvent.press(container);
202+
203+
expect(input.props.value).toBe('');
204+
});
205+
206+
test('handles focus and blur events', async () => {
207+
render(
208+
<OTPInput
209+
onChange={onChangeMock}
210+
maxLength={6}
211+
render={defaultRender}
212+
/>
213+
);
214+
215+
const input = await screen.findByTestId('otp-input');
216+
const cells = await screen.findByTestId('otp-cells');
217+
218+
fireEvent(input, 'focus');
219+
expect(cells.props['data-focused']).toBe(true);
220+
221+
fireEvent(input, 'blur');
222+
expect(cells.props['data-focused']).toBe(false);
223+
});
224+
});
225+
226+
describe('Validation', () => {
227+
test('respects pattern prop for input validation', async () => {
228+
render(
229+
<OTPInput
230+
onChange={onChangeMock}
231+
maxLength={6}
232+
pattern="^[0-9]+$"
233+
render={defaultRender}
234+
/>
235+
);
236+
237+
const input = await screen.findByTestId('otp-input');
238+
239+
// Test invalid input
240+
fireEvent.changeText(input, 'abc');
241+
expect(onChangeMock).not.toHaveBeenCalled();
242+
expect(input.props.value).toBe('');
243+
244+
// Test valid input
245+
fireEvent.changeText(input, '123');
246+
expect(onChangeMock).toHaveBeenCalledWith('123');
247+
expect(input.props.value).toBe('123');
248+
});
249+
250+
test('respects maxLength prop', async () => {
251+
render(
252+
<OTPInput
253+
onChange={onChangeMock}
254+
maxLength={4}
255+
render={defaultRender}
256+
/>
257+
);
258+
259+
const input = await screen.findByTestId('otp-input');
260+
fireEvent.changeText(input, '123456');
261+
262+
expect(input.props.value.length).toBeLessThanOrEqual(4);
263+
});
264+
});
265+
});

src/input.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export const OTPInput = React.forwardRef<TextInput, OTPInputProps>(
4141
}, [actions]);
4242

4343
return (
44-
<Pressable style={[styles.container, containerStyle]} onPress={onPress}>
44+
<Pressable
45+
testID="otp-input-container"
46+
style={[styles.container, containerStyle]}
47+
onPress={onPress}
48+
>
4549
{renderedChildren}
4650
<TextInput
4751
ref={inputRef}
@@ -55,6 +59,9 @@ export const OTPInput = React.forwardRef<TextInput, OTPInputProps>(
5559
inputMode={inputMode}
5660
autoComplete={Platform.OS === 'android' ? 'sms-otp' : 'one-time-code'}
5761
clearTextOnFocus
62+
accessible
63+
accessibilityRole="text"
64+
testID="otp-input"
5865
{...props}
5966
/>
6067
</Pressable>

src/types.tsx renamed to src/types.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,23 @@ export interface SlotProps {
66
placeholderChar: string | null;
77
hasFakeCaret: boolean;
88
}
9+
910
export interface RenderProps {
1011
slots: SlotProps[];
1112
isFocused: boolean;
1213
}
14+
1315
type OverrideProps<T, R> = Omit<T, keyof R> & R;
16+
17+
// TODO: remove values from the types as well as onTextChange
1418
type OTPInputBaseProps = OverrideProps<
1519
TextInputProps,
1620
{
1721
value?: string;
1822
onChange?: (newValue: string) => unknown;
1923

2024
maxLength: number;
21-
pattern?: string;
25+
pattern?: string | RegExp;
2226

2327
textAlign?: 'left' | 'center' | 'right';
2428

@@ -29,7 +33,9 @@ type OTPInputBaseProps = OverrideProps<
2933
containerStyle?: StyleProp<ViewStyle>;
3034
}
3135
>;
32-
type InputOTPRenderFn = (props: RenderProps) => React.ReactNode;
36+
37+
export type InputOTPRenderFn = (props: RenderProps) => React.ReactNode;
38+
3339
export type OTPInputProps = OTPInputBaseProps & {
34-
render: InputOTPRenderFn;
40+
render?: InputOTPRenderFn;
3541
};

0 commit comments

Comments
 (0)